How does server side internationalization (i18n) look like?
Adriano Raiano
Bridging the gap between developers and translators... i18next.com ?? locize.com | Always in search for #innovative and #disruptive stuff | Founder, CTO, Software/System Architect
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...
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.
#!/usr/bin/env node
const program = require('commander')
program
.command('sayhi')
.alias('s')
.option('-l, --language <lng>', 'by default the system language is used')
.option('-n, --name <name>', 'your name')
.action((options) => {
// options.language => optional language
// options.name => optional name
// TODO: log the salutation to the console...
})
.on('--help', () => {
console.log(' Examples:')
console.log()
console.log(' $ mycli sayhi')
console.log(' $ mycli sayhi --language de')
console.log(' $ mycli sayhi --language de --name John')
console.log()
})
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({
fallbackLng: 'en',
resources: {
en: {
translation: require('./locales/en/translation.json')
},
de: {
translation: require('./locales/de/translation.json')
}
}
})
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')
.alias('s')
.option('-l, --language <lng>', 'by default the system language is used')
.option('-n, --name <name>', 'your name')
.action((options) => {
const t = i18n(options.language)
if (options.name) {
console.log(t('salutationWithName', { name: options.name }))
} else {
console.log(t('salutation'))
}
})
.on('--help', () => {
console.log(' Examples:')
console.log()
console.log(' $ mycli sayhi')
console.log(' $ mycli sayhi --language de')
console.log(' $ mycli sayhi --language de --name John')
console.log()
})
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)
.init({
initImmediate: false, // setting initImediate to false, will load the resources synchronously
fallbackLng: 'en',
preload: readdirSync(localesFolder).filter((fileName) => {
const joinedPath = join(localesFolder, fileName)
return lstatSync(joinedPath).isDirectory()
}),
backend: {
loadPath: join(localesFolder, '{{lng}}/{{ns}}.json')
}
})
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
const mjml = pug.renderFile('./mailTemplate.pug', data)
// then transform the mjml syntax to normal html
const { html, errors } = mjml2html(mjml)
if (errors && errors.length > 0) throw new Error(errors[0].message)
// and return the html, if there where no errors
return html
}
The?mailTemplate.pug?could look like this:
mjml
mj-body(background-color='#F4F4F4' color='#55575d' font-family='Arial, sans-serif')
mj-section(background-color='#024b3f' background-repeat='repeat' padding='20px 0' text-align='center' vertical-align='top')
mj-column
mj-image(align='center' padding='10px 25px' src='https://raw.githubusercontent.com/i18next/i18next/master/assets/i18next-ecosystem.jpg')
mj-section(background-color='#ffffff' background-repeat='repeat' padding='20px 0' text-align='center' vertical-align='top')
mj-column
mj-section(background-color='#ffffff' background-repeat='repeat' background-size='auto' padding='20px 0px 20px 0px' text-align='center' vertical-align='top')
mj-column
mj-text(align='center' color='#55575d' font-family='Arial, sans-serif' font-size='20px' line-height='28px' padding='0px 25px 0px 25px')
span=t('greeting', { name: name || 'there' })
br
br
mj-text(align='center' color='#55575d' font-family='Arial, sans-serif' font-size='16px' line-height='28px' padding='0px 25px 0px 25px')
=t('text')
mj-section(background-color='#024b3f' background-repeat='repeat' padding='20px 0' text-align='center' vertical-align='top')
mj-column
mj-text(align='center' color='#ffffff' font-family='Arial, sans-serif' font-size='13px' line-height='22px' padding='10px 25px')
=t('ending')
a(style='color:#ffffff' )
b www.i18next.com
Now let's define some translations...
// locales/en/translations.json
{
"greeting": "Hi {{name}}!",
"text": "You were invited to try i18next.",
"ending": "Internationalized with"
}
// locales/de/translations.json
{
"greeting": "Hallo {{name}}!",
"text": "Du bist eingeladen worden i18next auszuprobieren.",
"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
.init({
// debug: true,
initImmediate: false, // setting initImediate to false, will load the resources synchronously
fallbackLng: 'en',
preload: readdirSync(localesFolder).filter((fileName) => {
const joinedPath = join(localesFolder, fileName)
return lstatSync(joinedPath).isDirectory()
}),
backend: {
loadPath: join(localesFolder, '{{lng}}/{{ns}}.json')
}
})
export default i18next
So 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...
.use(Backend) // you can also use any other i18next backend, like i18next-http-backend or i18next-locize-backend
.init({
initImmediate: false, // setting initImediate to false, will load the resources synchronously
fallbackLng: 'en',
preload: readdirSync(localesFolder).filter((fileName) => {
const joinedPath = join(localesFolder, fileName)
return lstatSync(joinedPath).isDirectory()
}),
backend: {
loadPath: join(localesFolder, '{{lng}}/{{ns}}.json')
}
})
export { i18next, i18nextPlugin: i18nextMiddleware.plugin }
And our translation resources...
// locales/en/translations.json
{
"home": {
"title": "Hello World!"
},
"server": {
"started": "Server is listening on port {{port}}."
}
}
// locales/de/translations.json
{
"home": {
"title": "Hallo Welt!"
},
"server": {
"started": "Der server lauscht auf dem Port {{port}}."
}
}
// locales/it/translations.json
{
"home": {
"title": "Ciao Mondo!"
},
"server": {
"started": "Il server sta aspettando sul port {{port}}."
}
}
A simple pug template:
领英推荐
html
head
title i18next - fastify with pug
body
h1=t('home.title')
div
a(href="/?lng=en") english
| |
a(href="/?lng=it") italiano
| |
a(href="/?lng=de") deutsch
Our "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)
// if you like you can also internationalize your log statements ;-)
console.log(i18next.t('server.started', { port }))
console.log(i18next.t('server.started', { port, lng: 'de' }))
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.
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:
// lambda.js
import awsLambdaFastify from 'aws-lambda-fastify'
import app from './app.js'
export 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)
// 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')
})
if (import.meta.url === `file://${process.argv[1]}`) {
// called directly (node app.js)
app.listen(port, (err) => {
if (err) return console.error(err)
console.log(i18next.t('server.started', { port }))
console.log(i18next.t('server.started', { port, lng: 'de' }))
console.log(i18next.t('server.started', { port, lng: 'it' }))
})
} else {
// imported as a module, i.e. when executed in AWS Lambda
}
export 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.
// _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')
return (
<>
<main>
<p>
{t('description')}
</p>
</main>
</>
)
}
export const getStaticProps = async ({ locale }) => ({
props: {
...await serverSideTranslations(locale, ['common']),
// Will be passed to the page component as props
},
})
export default Homepage
By default,?next-i18next?expects your translations to be organised 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 2 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 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
???? 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.
??