Tutorial · Node.js

How to Build a Multilingual App with Node.js

Complete Express.js tutorial with SocketsIO Translation API — i18n best practices, caching, batch translation, and production-ready code.

Updated March 2026 · 15 min read

Table of Contents

  1. Prerequisites
  2. Project Setup
  3. Basic Translation with SocketsIO
  4. Express Middleware for i18n
  5. Caching Translations (Redis + In-Memory)
  6. Batch Translation for Performance
  7. Automatic Language Detection
  8. i18n Best Practices
  9. Production Deployment

Adding multilingual support to a Node.js app doesn't have to be complicated or expensive. In this tutorial, we'll build a complete Express.js application with SocketsIO Translation API — covering everything from basic translation to production-ready caching and batch processing.

What you'll build: A multilingual Express API that detects user language, translates content on-the-fly, caches results, and handles batch translation — all for less than $1/month at typical SaaS scale.

Prerequisites

Project Setup

mkdir multilingual-app && cd multilingual-app
npm init -y
npm install express axios dotenv node-cache

Create a .env file:

SOCKETSIO_API_KEY=your_api_key_here
PORT=3000

Basic Translation with SocketsIO

Let's start with a simple translation helper:

// lib/translate.js
const axios = require('axios');

const API_BASE = 'https://api.socketsio.com/v1';

async function translate(text, targetLang, sourceLang = null) {
  const payload = {
    q: text,
    target: targetLang,
  };
  if (sourceLang) payload.source = sourceLang;

  const response = await axios.post(`${API_BASE}/translate`, payload, {
    headers: {
      'Authorization': `Bearer ${process.env.SOCKETSIO_API_KEY}`,
      'Content-Type': 'application/json',
    },
  });

  return response.data.data.translations[0].translatedText;
}

async function detectLanguage(text) {
  const response = await axios.post(`${API_BASE}/detect`, { q: text }, {
    headers: {
      'Authorization': `Bearer ${process.env.SOCKETSIO_API_KEY}`,
    },
  });
  return response.data.data.detections[0][0].language;
}

module.exports = { translate, detectLanguage };

Test it works:

// test.js
require('dotenv').config();
const { translate, detectLanguage } = require('./lib/translate');

async function main() {
  const result = await translate('Hello, world!', 'es');
  console.log(result); // → "¡Hola, mundo!"

  const lang = await detectLanguage('Bonjour le monde');
  console.log(lang); // → "fr"
}

main();

Express Middleware for i18n

Now let's build an Express middleware that automatically detects the user's preferred language and makes translation available in route handlers:

// middleware/i18n.js
const { detectLanguage, translate } = require('../lib/translate');

const SUPPORTED_LANGUAGES = ['en', 'es', 'fr', 'de', 'ja', 'zh', 'pt', 'ar', 'hi', 'ko'];
const DEFAULT_LANGUAGE = 'en';

function getPreferredLanguage(req) {
  // 1. Check query param: ?lang=es
  if (req.query.lang && SUPPORTED_LANGUAGES.includes(req.query.lang)) {
    return req.query.lang;
  }

  // 2. Check Accept-Language header
  const acceptLang = req.headers['accept-language'];
  if (acceptLang) {
    const preferred = acceptLang.split(',')[0].split('-')[0].trim();
    if (SUPPORTED_LANGUAGES.includes(preferred)) return preferred;
  }

  return DEFAULT_LANGUAGE;
}

function i18nMiddleware(req, res, next) {
  req.lang = getPreferredLanguage(req);

  // Helper: translate a string to the user's language
  req.t = async (text, sourceLang = 'en') => {
    if (req.lang === sourceLang) return text;
    return translate(text, req.lang, sourceLang);
  };

  next();
}

module.exports = i18nMiddleware;
// app.js
require('dotenv').config();
const express = require('express');
const i18n = require('./middleware/i18n');

const app = express();
app.use(express.json());
app.use(i18n);

app.get('/hello', async (req, res) => {
  const message = await req.t('Welcome to our app! We support 195 languages.');
  res.json({ message, language: req.lang });
});

app.listen(process.env.PORT, () => {
  console.log(`Server running on port ${process.env.PORT}`);
});
Test it: curl "http://localhost:3000/hello?lang=ja" → returns the message in Japanese.

Caching Translations (In-Memory + Redis)

Translating the same string repeatedly wastes API credits. Add caching to avoid redundant calls:

// lib/translate-cached.js
const NodeCache = require('node-cache');
const { translate: rawTranslate, detectLanguage } = require('./translate');

// In-memory cache: 24 hour TTL
const cache = new NodeCache({ stdTTL: 86400, checkperiod: 3600 });

function cacheKey(text, target, source) {
  return `${source || 'auto'}:${target}:${Buffer.from(text).toString('base64').slice(0, 32)}`;
}

async function translate(text, targetLang, sourceLang = null) {
  const key = cacheKey(text, targetLang, sourceLang);

  // Check cache first
  const cached = cache.get(key);
  if (cached !== undefined) {
    return cached;
  }

  // Cache miss — call API
  const result = await rawTranslate(text, targetLang, sourceLang);
  cache.set(key, result);
  return result;
}

// Cache stats endpoint
function getCacheStats() {
  return cache.getStats();
}

module.exports = { translate, detectLanguage, getCacheStats };

For production, use Redis for shared caching across multiple Node.js instances:

// lib/translate-redis.js
const redis = require('redis');
const { translate: rawTranslate } = require('./translate');

const client = redis.createClient({ url: process.env.REDIS_URL });
client.connect();

const CACHE_TTL = 86400; // 24 hours

async function translate(text, targetLang, sourceLang = null) {
  const key = `sio:${sourceLang || 'auto'}:${targetLang}:${text.slice(0, 100)}`;

  // Try Redis cache
  const cached = await client.get(key);
  if (cached) return cached;

  // Cache miss
  const result = await rawTranslate(text, targetLang, sourceLang);
  await client.setEx(key, CACHE_TTL, result);
  return result;
}

module.exports = { translate };

Batch Translation for Performance

When you need to translate multiple strings (e.g., a product catalog), use batch translation to reduce API calls and latency:

// lib/batch-translate.js
const axios = require('axios');

const API_BASE = 'https://api.socketsio.com/v1';

/**
 * Translate multiple strings in a single API call.
 * @param {string[]} texts - Array of strings to translate
 * @param {string} targetLang - Target language code
 * @param {string|null} sourceLang - Source language (null = auto-detect)
 * @returns {Promise} - Array of translated strings
 */
async function batchTranslate(texts, targetLang, sourceLang = null) {
  const payload = {
    q: texts,  // Pass array directly
    target: targetLang,
  };
  if (sourceLang) payload.source = sourceLang;

  const response = await axios.post(`${API_BASE}/translate`, payload, {
    headers: {
      'Authorization': `Bearer ${process.env.SOCKETSIO_API_KEY}`,
      'Content-Type': 'application/json',
    },
  });

  return response.data.data.translations.map(t => t.translatedText);
}

// Example: translate a product catalog
async function translateProductCatalog(products, targetLang) {
  const names = products.map(p => p.name);
  const descriptions = products.map(p => p.description);

  // Two batch calls instead of 2N individual calls
  const [translatedNames, translatedDescs] = await Promise.all([
    batchTranslate(names, targetLang),
    batchTranslate(descriptions, targetLang),
  ]);

  return products.map((product, i) => ({
    ...product,
    name: translatedNames[i],
    description: translatedDescs[i],
    lang: targetLang,
  }));
}

module.exports = { batchTranslate, translateProductCatalog };
// Usage example
const { translateProductCatalog } = require('./lib/batch-translate');

const products = [
  { id: 1, name: 'Wireless Headphones', description: 'Premium audio quality' },
  { id: 2, name: 'Laptop Stand', description: 'Ergonomic aluminum design' },
  { id: 3, name: 'USB-C Hub', description: '7-in-1 connectivity solution' },
];

const spanishProducts = await translateProductCatalog(products, 'es');
// Returns all 3 products with Spanish name + description
// Only 2 API calls instead of 6!

Automatic Language Detection

Let users write in their native language and auto-detect it:

// routes/support.js
const express = require('express');
const { detectLanguage, translate } = require('../lib/translate-cached');

const router = express.Router();

// Support ticket endpoint — auto-detects language, translates to English for agents
router.post('/ticket', async (req, res) => {
  const { message, email } = req.body;

  // Detect what language the user wrote in
  const detectedLang = await detectLanguage(message);

  let englishMessage = message;
  if (detectedLang !== 'en') {
    // Translate to English for support agents
    englishMessage = await translate(message, 'en', detectedLang);
  }

  // Store ticket with both versions
  const ticket = {
    id: Date.now(),
    email,
    originalMessage: message,
    originalLanguage: detectedLang,
    englishMessage,
    createdAt: new Date().toISOString(),
  };

  // ... save to database

  // Send confirmation in user's language
  const confirmation = await translate(
    'Your support ticket has been received. We will respond within 24 hours.',
    detectedLang,
    'en'
  );

  res.json({ ticketId: ticket.id, message: confirmation });
});

module.exports = router;

i18n Best Practices

1. Cache aggressively

UI strings (buttons, labels, error messages) are translated thousands of times. Cache them for 24+ hours. Dynamic content (user-generated text) can have shorter TTLs.

2. Use source language hints

Always pass source when you know the source language. Auto-detection adds ~20ms latency and costs extra characters for the detection call.

3. Batch static content at startup

// Translate all UI strings at app startup
const UI_STRINGS = require('./locales/en.json');

async function preloadTranslations(languages) {
  const translations = {};
  for (const lang of languages) {
    const keys = Object.keys(UI_STRINGS);
    const values = Object.values(UI_STRINGS);
    const translated = await batchTranslate(values, lang, 'en');
    translations[lang] = Object.fromEntries(keys.map((k, i) => [k, translated[i]]));
  }
  return translations;
}

// At startup:
const TRANSLATIONS = await preloadTranslations(['es', 'fr', 'de', 'ja', 'zh']);

4. Respect RTL languages

const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur', 'yi'];

app.use((req, res, next) => {
  res.locals.dir = RTL_LANGUAGES.includes(req.lang) ? 'rtl' : 'ltr';
  next();
});

5. Handle errors gracefully

async function translateWithFallback(text, targetLang, fallback = text) {
  try {
    return await translate(text, targetLang);
  } catch (err) {
    console.error('Translation failed:', err.message);
    return fallback; // Return original text on failure
  }
}

Production Deployment

ConcernRecommendation
API key securityStore in environment variables, never in code
Rate limitingAdd request queuing for burst traffic
CachingRedis for multi-instance deployments
Error handlingAlways fall back to original text
Cost monitoringTrack character usage via /v1/usage endpoint
Language detectionCache detected languages per user session
Cost estimate: A typical SaaS app with 1,000 users translating 500 characters each = 500K chars/month. That's within SocketsIO's free tier. At 10M chars/month, you pay $9.99/month flat.

Ready to Add Multilingual Support?

500,000 free characters every month. No credit card. Works with Node.js, Python, PHP, and any HTTP client.

Get Your Free API Key →

Or try the interactive playground first — no signup needed.

Related Articles