Skip to main content

How to add i18n to a Node.js backend (transactional emails, APIs, push notifications)

· 10 min read
Founder of i18n-keyless

Backend localization is the i18n problem people forget about. Your frontend speaks five languages, and then your transactional emails go out in English regardless of user locale. Or your push notifications. Or your error messages from the API.

This guide shows how to localize a Node.js backend (Express, Fastify, Hono, or plain Node) with i18n-keyless-node — for emails, push notifications, API responses, scheduled jobs, and anywhere else strings flow from server to user.

TL;DR

npm install i18n-keyless-node
import * as I18nKeyless from "i18n-keyless-node";
import { awaitForTranslation } from "i18n-keyless-node";

I18nKeyless.init({
API_KEY: process.env.I18N_KEYLESS_API_KEY!,
languages: { primary: "en", supported: ["en", "fr", "de", "es"] },
});

// Translate a string for a specific user's locale
const subject = await awaitForTranslation("Welcome to Acme!", user.lang);
const body = await awaitForTranslation("Click here to verify your email.", user.lang);

await sendEmail(user.email, subject, body);

That's the whole pattern. awaitForTranslation(source, targetLang) returns the translated string. Cached server-side, generated by AI on first encounter.

Why server-side i18n is a separate problem

Frontend i18n libraries (i18next, react-intl, i18n-keyless-react) translate strings at render time, in the user's browser. They don't help when:

  • You send transactional emails from a server (signup confirmation, password reset, invoice, weekly digest).
  • You send push notifications.
  • Your API returns user-facing error messages.
  • A scheduled job emails users a summary in their preferred language.
  • You generate PDFs, receipts, or other server-rendered content.

For all of these, the user's locale matters but the rendering happens server-side. You need a translation function that runs in Node.

Step 1: Install the SDK

npm install i18n-keyless-node

The package is server-only — no DOM, no React. Just a fetch + cache layer over the translation API.

Step 2: Initialize once at startup

// src/i18n.ts
import * as I18nKeyless from "i18n-keyless-node";

I18nKeyless.init({
API_KEY: process.env.I18N_KEYLESS_API_KEY!,
languages: {
primary: "en",
supported: ["en", "fr", "de", "es"],
},
});

export { awaitForTranslation, type Lang } from "i18n-keyless-node";

Import this once at app startup (e.g., from your entrypoint):

// src/index.ts
import "./i18n.ts"; // initialize i18n-keyless first
import express from "express";
// ...rest of your app

Step 3: Translate a string for a user's locale

The core API is awaitForTranslation(source, targetLang):

import { awaitForTranslation } from "./i18n.ts";

const greeting = await awaitForTranslation("Welcome to Acme!", "fr");
// → "Bienvenue chez Acme !"

Every user in your DB should have a lang field (or similar). Use it as the targetLang:

const subject = await awaitForTranslation("Your invoice is ready", user.lang);

Step 4: Localize transactional emails

Real example — a signup welcome email:

import { awaitForTranslation } from "./i18n.ts";

async function sendWelcomeEmail(user: { email: string; lang: string; name: string }) {
const subject = await awaitForTranslation("Welcome to Acme!", user.lang);

const greeting = await awaitForTranslation(
"Hi {name}, thanks for signing up.",
user.lang,
{ replace: { '{name}': user.name } },
);
const body = await awaitForTranslation(
"We're excited to have you on board. Click below to verify your email.",
user.lang,
);
const cta = await awaitForTranslation("Verify email", user.lang);

await emailProvider.send({
to: user.email,
subject,
html: `
<h1>${subject}</h1>
<p>${greeting}</p>
<p>${body}</p>
<a href="${verifyUrl}">${cta}</a>
`,
});
}

Three patterns to notice:

  1. Translate once per send. Each awaitForTranslation is a cache lookup; first call for a (source, lang) pair triggers AI translation; subsequent calls hit cache.
  2. Pass interpolation values via the replace option. The SDK substitutes placeholders like {name} after translation, so substitution works in any locale. Don't .replace() manually — the placeholder may shift position during translation.
  3. Always await sequentially or in parallel — don't fire 100 in a forEach. See the rate-limit warning below.

Step 5: Localize push notifications

Same pattern, smaller payload:

async function sendNewMessageNotification(recipient: User, message: Message) {
const title = await awaitForTranslation("New message", recipient.lang);
const body = await awaitForTranslation(
"{sender} sent you a message: {preview}",
recipient.lang,
{
replace: {
'{sender}': message.senderName,
'{preview}': message.preview.slice(0, 80),
},
},
);

await pushService.send({
deviceToken: recipient.deviceToken,
title,
body,
});
}

Step 6: Localize API error responses

Express error handler:

import { Request, Response, NextFunction } from "express";
import { awaitForTranslation } from "./i18n.ts";

interface AppError extends Error {
statusCode?: number;
userMessage?: string;
}

async function errorHandler(
err: AppError,
req: Request,
res: Response,
next: NextFunction,
) {
const userLang = req.user?.lang ?? req.headers["accept-language"]?.split(",")[0]?.split("-")[0] ?? "en";

const message = err.userMessage
? await awaitForTranslation(err.userMessage, userLang)
: await awaitForTranslation("Something went wrong. Please try again.", userLang);

res.status(err.statusCode ?? 500).json({ error: message });
}

app.use(errorHandler);

Throw user-facing errors with a userMessage field; the error handler translates them based on the request's locale.

Step 7: Batch translation for jobs

Sending 10,000 emails in a daily digest? Don't fire 10,000 sequential awaitForTranslation calls — that's slow and rate-limit-prone. The SDK caches per (source, lang) pair so the first email's translation lookups are cached for the rest:

async function sendDailyDigest(users: User[]) {
// Pre-warm common strings once per locale, not per user
const locales = [...new Set(users.map((u) => u.lang))];
await Promise.all(
locales.flatMap((lang) => [
awaitForTranslation("Your daily digest", lang),
awaitForTranslation("Here's what happened today.", lang),
awaitForTranslation("Read more", lang),
]),
);

// Now per-user sends are cached
for (const user of users) {
await sendDigest(user); // each call hits cache
}
}

The same pattern applies to scheduled jobs, batch invoice generation, weekly summaries, etc.

Step 8: Avoid rate limit pitfalls

⚠️ Important: awaitForTranslation makes an HTTP request on cache miss. Calling it 1,000 times in a loop without await will overwhelm the API.

// ❌ WRONG — fires 1000 parallel requests, may rate-limit
users.forEach((user) => {
awaitForTranslation("Hello", user.lang); // missing await
});

// ✅ CORRECT — sequential, cache fills naturally
for (const user of users) {
await awaitForTranslation("Hello", user.lang);
}

// ✅ ALSO CORRECT — bounded concurrency
import pLimit from "p-limit";
const limit = pLimit(5);
await Promise.all(users.map((u) => limit(() => awaitForTranslation("Hello", u.lang))));

The SDK has its own request batching and caching, but you still want to await calls in async paths to avoid floods.

Step 9: Combine with frontend i18n

Most apps need both i18n-keyless-node (backend) and i18n-keyless-react (frontend) — they share the same API key and the same translation backend, so a string translated server-side is also available client-side, and vice versa.

Patterns:

  • Frontend renders user-typed content as-is, server translates only the strings you control (UI copy, errors, emails).
  • Backend pre-translates server-rendered HTML/email so the user receives a fully-localized email in their language without any client-side translation.
  • Cache is shared between server and client — translating "Welcome" once on the server caches it for subsequent client renders too.

See the dedicated frontend guides:

Step 10: Generate localized PDFs / invoices

Same pattern — translate the strings, then render:

import { awaitForTranslation } from "./i18n.ts";
import PDFDocument from "pdfkit";

async function generateInvoice(invoice: Invoice, user: User) {
const lang = user.lang;
const labels = {
title: await awaitForTranslation("Invoice", lang),
date: await awaitForTranslation("Date", lang),
amount: await awaitForTranslation("Amount", lang),
description: await awaitForTranslation("Description", lang),
total: await awaitForTranslation("Total", lang),
thanks: await awaitForTranslation("Thank you for your business.", lang),
};

const doc = new PDFDocument();
doc.fontSize(20).text(labels.title);
doc.fontSize(12).text(`${labels.date}: ${invoice.date.toLocaleDateString(lang)}`);
doc.text(`${labels.description}: ${invoice.description}`);
doc.text(`${labels.total}: ${invoice.total}`);
doc.text(labels.thanks);

return doc;
}

For multiple invoices in a batch, the labels dictionary is cached after the first invoice — subsequent generations are fast.

Common gotchas

Auth keys: public vs private

The SDK's API_KEY is the public bearer token. Don't expose your private_key (admin / management key) on any client surface — keep it server-only.

Concurrent requests on cold start

When your Node process boots and serves its first request in a new locale, the translation cache is empty. The first request might pay 200–500ms for the cache miss. Subsequent requests are fast (≤5ms — local cache hit).

If you have strict latency SLAs, pre-warm the cache during app startup:

// src/i18n.ts
import { awaitForTranslation } from "i18n-keyless-node";

const COMMON_STRINGS = [
"Welcome to Acme!",
"Verify email",
"Password reset",
// ... your top 50 transactional strings
];

const SUPPORTED = ["en", "fr", "de", "es"];

export async function preWarmCache() {
await Promise.all(
SUPPORTED.flatMap((lang) =>
COMMON_STRINGS.map((s) => awaitForTranslation(s, lang)),
),
);
}

Call preWarmCache() after init.

Worker threads / cluster mode

Each worker has its own in-memory cache. If you run Node in cluster mode (multiple processes), each process pays its own cold-start cache fill. The translation backend deduplicates so you don't pay for redundant AI calls — just network round-trips.

Serverless / Lambda

The cache lives in the process. Cold-start invocations re-fetch. To mitigate:

  • Use longer-lived containers (provisioned concurrency on Lambda, etc.).
  • Cache translations in Redis or DynamoDB at your application layer for sub-millisecond hot path.
  • Or accept the cold-start latency for translation-light functions.

Plain text vs HTML translation

awaitForTranslation is content-agnostic — pass any string. If you pass HTML ("<b>Hello</b>"), the AI preserves the tags. For complex templates, translate sentences and assemble HTML around them rather than translating big HTML blobs (cleaner and more cacheable).

When to use a different approach

  • ❌ Need fully offline / no-network translation → use a bundled JSON file approach.
  • ❌ Need ICU MessageFormat plural rules → use i18next server-side.
  • ❌ Need non-AI human-curated translations only → use a TMS like Lokalise + their server SDK.

For everyone else — emails, notifications, API errors — keyless server-side is fastest.

FAQ

Does i18n-keyless-node work in Bun / Deno?

Yes. The SDK uses standard Node.js APIs (fetch, etc.) compatible with Bun and Deno's Node compatibility layers.

Can I use it with serverless functions (Vercel, Cloudflare Workers, AWS Lambda)?

Yes. The SDK is just fetch + an in-memory cache. Cold-start invocations re-fetch on cache miss; warm invocations hit cache.

How is the translation cache shared between server and frontend?

Both SDKs hit the same backend. A translation generated by the server (via awaitForTranslation) is immediately available to the frontend SDK on its next refresh. Same translations, two access paths.

What about localizing locale-specific dates / numbers / currencies?

Use native Intl.* APIs server-side: new Intl.DateTimeFormat(lang).format(date). The SDK handles strings; date/number formatting is independent.

How do I handle email subject + body in one go?

Just call awaitForTranslation for each. Or build a helper:

async function translateEmail(strings: Record<string, string>, lang: string) {
const entries = await Promise.all(
Object.entries(strings).map(async ([k, v]) => [k, await awaitForTranslation(v, lang)] as const),
);
return Object.fromEntries(entries);
}

Next steps