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 verry 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.
1#!/usr/bin/env node
2
3const program = require('commander')
4
5program
6 .command('sayhi')
7 .alias('s')
8 .option('-l, --language <lng>', 'by default the system language is used')
9 .option('-n, --name <name>', 'your name')
10 .action((options) => {
11 // options.language => optional language
12 // options.name => optional name
13 // TODO: log the salutation to the console...
14 })
15 .on('--help', () => {
16 console.log(' Examples:')
17 console.log()
18 console.log(' $ mycli sayhi')
19 console.log(' $ mycli sayhi --language de')
20 console.log(' $ mycli sayhi --language de --name John')
21 console.log()
22 })
23
24program.parse(process.argv)
25
26if (!process.argv.slice(2).length) {
27 program.outputHelp()
28}
Ok, now let's create a new i18n.js
file and setup i18next accordingly:
1const i18next = require('i18next')
2
3// if no language parameter is passed, let's try to use the node.js system's locale
4const systemLocale = Intl.DateTimeFormat().resolvedOptions().locale
5
6i18next
7 .init({
8 fallbackLng: 'en',
9 resources: {
10 en: {
11 translation: require('./locales/en/translation.json')
12 },
13 de: {
14 translation: require('./locales/de/translation.json')
15 }
16 }
17 })
18
19module.exports = (lng) => i18next.getFixedT(lng || systemLocale)
And also our translation resources:
1// locales/en/translations.json
2{
3 "salutation": "Hello World!",
4 "salutationWithName": "Hello {{name}}!"
5}
6
7// locales/de/translations.json
8{
9 "salutation": "Hallo Welt!",
10 "salutationWithName": "Hallo {{name}}!"
11}
Now we can use the i18n.js
export like that:
1#!/usr/bin/env node
2
3const program = require('commander')
4const i18n = require('../i18n.js')
5
6program
7 .command('sayhi')
8 .alias('s')
9 .option('-l, --language <lng>', 'by default the system language is used')
10 .option('-n, --name <name>', 'your name')
11 .action((options) => {
12 const t = i18n(options.language)
13 if (options.name) {
14 console.log(t('salutationWithName', { name: options.name }))
15 } else {
16 console.log(t('salutation'))
17 }
18 })
19 .on('--help', () => {
20 console.log(' Examples:')
21 console.log()
22 console.log(' $ mycli sayhi')
23 console.log(' $ mycli sayhi --language de')
24 console.log(' $ mycli sayhi --language de --name John')
25 console.log()
26 })
27
28program.parse(process.argv)
29
30if (!process.argv.slice(2).length) {
31 program.outputHelp()
32}
Ok, what's the result?
1## if we execute the cli command without any parameters...
2mycli sayhi
3## result: Hello World!
4
5## if we execute the cli command with a language parameter...
6mycli sayhi --language de
7## result: Hallo Welt!
8
9## if we execute the cli command with a language parameter and a name parameter...
10mycli sayhi --language de --name John
11## result: Hallo John!
12
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:
1const i18next = require('i18next')
2const Backend = require('i18next-fs-backend')
3const { join } = require('path')
4const { readdirSync, lstatSync } = require('fs')
5
6// if no language parameter is passed, let's try to use the node.js system's locale
7const systemLocale = Intl.DateTimeFormat().resolvedOptions().locale
8
9const localesFolder = join(__dirname, './locales')
10
11i18next
12 .use(Backend)
13 .init({
14 initImmediate: false, // setting initImediate to false, will load the resources synchronously
15 fallbackLng: 'en',
16 preload: readdirSync(localesFolder).filter((fileName) => {
17 const joinedPath = join(localesFolder, fileName)
18 return lstatSync(joinedPath).isDirectory()
19 }),
20 backend: {
21 loadPath: join(localesFolder, '{{lng}}/{{ns}}.json')
22 }
23 })
24
25module.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.
1import pug from 'pug'
2import mjml2html from 'mjml'
3
4export default (data) => {
5 // first let's compile and render the mail template that will include the data needed to show in the mail content
6 const mjml = pug.renderFile('./mailTemplate.pug', data)
7
8 // then transform the mjml syntax to normal html
9 const { html, errors } = mjml2html(mjml)
10 if (errors && errors.length > 0) throw new Error(errors[0].message)
11
12 // and return the html, if there where no errors
13 return html
14}
The mailTemplate.pug
could look like this:
1mjml
2 mj-body(background-color='#F4F4F4' color='#55575d' font-family='Arial, sans-serif')
3 mj-section(background-color='#024b3f' background-repeat='repeat' padding='20px 0' text-align='center' vertical-align='top')
4 mj-column
5 mj-image(align='center' padding='10px 25px' src='https://raw.githubusercontent.com/i18next/i18next/master/assets/i18next-ecosystem.jpg')
6 mj-section(background-color='#ffffff' background-repeat='repeat' padding='20px 0' text-align='center' vertical-align='top')
7 mj-column
8 mj-section(background-color='#ffffff' background-repeat='repeat' background-size='auto' padding='20px 0px 20px 0px' text-align='center' vertical-align='top')
9 mj-column
10 mj-text(align='center' color='#55575d' font-family='Arial, sans-serif' font-size='20px' line-height='28px' padding='0px 25px 0px 25px')
11 span=t('greeting', { name: name || 'there' })
12 br
13 br
14 mj-text(align='center' color='#55575d' font-family='Arial, sans-serif' font-size='16px' line-height='28px' padding='0px 25px 0px 25px')
15 =t('text')
16 mj-section(background-color='#024b3f' background-repeat='repeat' padding='20px 0' text-align='center' vertical-align='top')
17 mj-column
18 mj-text(align='center' color='#ffffff' font-family='Arial, sans-serif' font-size='13px' line-height='22px' padding='10px 25px')
19 =t('ending')
20 a(style='color:#ffffff' href='https://www.i18next.com')
21 b www.i18next.com
Now let's define some translations...
1// locales/en/translations.json
2{
3 "greeting": "Hi {{name}}!",
4 "text": "You were invited to try i18next.",
5 "ending": "Internationalized with"
6}
7
8// locales/de/translations.json
9{
10 "greeting": "Hallo {{name}}!",
11 "text": "Du bist eingeladen worden i18next auszuprobieren.",
12 "ending": "Internationalisiert mit"
13}
...and use them in an i18n.js
file:
1import { dirname, join } from 'path'
2import { readdirSync, lstatSync } from 'fs'
3import { fileURLToPath } from 'url'
4import i18next from 'i18next'
5import Backend from 'i18next-fs-backend'
6
7const __dirname = dirname(fileURLToPath(import.meta.url))
8const localesFolder = join(__dirname, './locales')
9
10i18next
11 .use(Backend) // you can also use any other i18next backend, like i18next-http-backend or i18next-locize-backend
12 .init({
13 // debug: true,
14 initImmediate: false, // setting initImediate to false, will load the resources synchronously
15 fallbackLng: 'en',
16 preload: readdirSync(localesFolder).filter((fileName) => {
17 const joinedPath = join(localesFolder, fileName)
18 return lstatSync(joinedPath).isDirectory()
19 }),
20 backend: {
21 loadPath: join(localesFolder, '{{lng}}/{{ns}}.json')
22 }
23 })
24
25export default i18next
So finally, all the above can be used like that:
1import mail from './mail.js'
2
3import i18next from './i18n.js'
4
5const html = mail({
6 t: i18next.t,
7 name: 'John'
8})
9// that html now can be sent via some mail provider...
10
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:
1import { dirname, join } from 'path'
2import { readdirSync, lstatSync } from 'fs'
3import { fileURLToPath } from 'url'
4import i18next from 'i18next'
5import Backend from 'i18next-fs-backend'
6import i18nextMiddleware from 'i18next-http-middleware'
7
8const __dirname = dirname(fileURLToPath(import.meta.url))
9const localesFolder = join(__dirname, '../locales')
10
11i18next
12 .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...
13 .use(Backend) // you can also use any other i18next backend, like i18next-http-backend or i18next-locize-backend
14 .init({
15 initImmediate: false, // setting initImediate to false, will load the resources synchronously
16 fallbackLng: 'en',
17 preload: readdirSync(localesFolder).filter((fileName) => {
18 const joinedPath = join(localesFolder, fileName)
19 return lstatSync(joinedPath).isDirectory()
20 }),
21 backend: {
22 loadPath: join(localesFolder, '{{lng}}/{{ns}}.json')
23 }
24 })
25
26export { i18next, i18nextPlugin: i18nextMiddleware.plugin }
And our translation resources...
1// locales/en/translations.json
2{
3 "home": {
4 "title": "Hello World!"
5 },
6 "server": {
7 "started": "Server is listening on port {{port}}."
8 }
9}
10
11// locales/de/translations.json
12{
13 "home": {
14 "title": "Hallo Welt!"
15 },
16 "server": {
17 "started": "Der server lauscht auf dem Port {{port}}."
18 }
19}
20
21// locales/it/translations.json
22{
23 "home": {
24 "title": "Ciao Mondo!"
25 },
26 "server": {
27 "started": "Il server sta aspettando sul port {{port}}."
28 }
29}
A simple pug template:
1html
2 head
3 title i18next - fastify with pug
4 body
5 h1=t('home.title')
6 div
7 a(href="/?lng=en") english
8 | |
9 a(href="/?lng=it") italiano
10 | |
11 a(href="/?lng=de") deutsch
Our "main" file app.js
:
1import fastify from 'fastify'
2import pov from 'point-of-view'
3import pug from 'pug'
4import { i18next, i18nextPlugin } from './lib/i18n.js'
5
6const port = process.env.PORT || 8080
7
8const app = fastify()
9app.register(pov, { engine: { pug } })
10app.register(i18nextPlugin, { i18next })
11
12app.get('/raw', (request, reply) => {
13 reply.send(request.t('home.title'))
14})
15
16app.get('/', (request, reply) => {
17 reply.view('/views/index.pug')
18})
19
20app.listen(port, (err) => {
21 if (err) return console.error(err)
22 // if you like you can also internationalize your log statements ;-)
23 console.log(i18next.t('server.started', { port }))
24 console.log(i18next.t('server.started', { port, lng: 'de' }))
25 console.log(i18next.t('server.started', { port, lng: 'it' }))
26})
ow 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.
A possible next step...
Do you wish to manage your translations in a translation management system (TMS), like locize?
Just use this cli to synchronize the translations with your code. To see how this could look like check out Step 1 in this tutorial.
Alternatively, use i18next-locize-backend instead of the i18next-fs-backend. If you're running your code in a serverless environment, make sure you read this advice first!
btw: Did you know, you can easily adapt your Fastify app to be used in AWS Lambda AND locally.
This can be achieved with the help of aws-lambda-fastify. Just create a new lambda.js
that imports your modified app.js
file:
1// lambda.js
2import awsLambdaFastify from 'aws-lambda-fastify'
3import app from './app.js'
4export const handler = awsLambdaFastify(app)
make sure your Fastify app is exported... (export default app
) And only start to listen on a port, if not executed in AWS Lambda (import.meta.url === 'file://${process.argv[1]}'
or require.main === module
for CommonJS)
1// app.js
2import fastify from 'fastify'
3import pov from 'point-of-view'
4import pug from 'pug'
5import { i18next, i18nextPlugin } from './lib/i18n.js'
6
7const port = process.env.PORT || 8080
8
9const app = fastify()
10app.register(pov, { engine: { pug } })
11app.register(i18nextPlugin, { i18next })
12
13app.get('/raw', (request, reply) => {
14 reply.send(request.t('home.title'))
15})
16
17app.get('/', (request, reply) => {
18 reply.view('/views/index.pug')
19})
20
21if (import.meta.url === `file://${process.argv[1]}`) {
22 // called directly (node app.js)
23 app.listen(port, (err) => {
24 if (err) return console.error(err)
25 console.log(i18next.t('server.started', { port }))
26 console.log(i18next.t('server.started', { port, lng: 'de' }))
27 console.log(i18next.t('server.started', { port, lng: 'it' }))
28 })
29} else {
30 // imported as a module, i.e. when executed in AWS Lambda
31}
32
33export default app
😎 Cool, right?
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.
1// _app.js
2import { appWithTranslation } from 'next-i18next'
3
4const MyApp = ({ Component, pageProps }) => <Component {...pageProps} />
5
6export default appWithTranslation(MyApp)
1// index.js
2import { useTranslation } from 'next-i18next'
3import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
4// 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)
5
6const Homepage = () => {
7 const { t } = useTranslation('common')
8
9 return (
10 <>
11 <main>
12 <p>
13 {t('description')}
14 </p>
15 </main>
16 </>
17 )
18}
19
20export const getStaticProps = async ({ locale }) => ({
21 props: {
22 ...await serverSideTranslations(locale, ['common']),
23 // Will be passed to the page component as props
24 },
25})
26
27export default Homepage
By default, next-i18next
expects your translations to be organized as such:
.
└── public
└── locales
├── en
| └── common.json
└── de
└── common.json
A 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:
1// next-i18next.config.js
2module.exports = {
3 i18n: {
4 defaultLocale: 'en',
5 locales: ['en', 'de'],
6 },
7 backend: {
8 projectId: 'd3b405cf-2532-46ae-adb8-99e88d876733',
9 // apiKey: 'myApiKey', // to not add the api-key in production, used for saveMissing feature
10 referenceLng: 'en'
11 },
12 use: [
13 require('i18next-locize-backend/cjs')
14 ],
15 ns: ['common', 'footer', 'second-page'], // the namespaces needs to be listed here, to make sure they got preloaded
16 serializeConfig: false, // because of the custom use i18next plugin
17 // debug: true,
18 // saveMissing: true, // to not saveMissing to true for production
19}
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:
1// next-i18next.config.js
2module.exports = {
3 i18n: {
4 defaultLocale: 'en',
5 locales: ['en', 'de'],
6 }
7}
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.
👍