Speak your users' language: bot localization

A small, honest i18n pattern for a discord.js bot — JSON locale files, a tiny t() helper with fallbacks, and a stored per-server language choice. Verified end to end, storage included.

Once your bot has users in more than one country, replying only in English starts to feel unfinished. Localization (i18n) doesn't need a heavy framework — a few JSON files, a small lookup helper, and somewhere to remember each server's choice will do it. This is that pattern, kept deliberately simple.

At a glance
You need a working discord.js bot on a Falix Node.js server
Plan free or premium
Time about twenty minutes

1. One file per language

Put your translations in a locales/ folder, one JSON file per language. The keys are the same everywhere; only the values change. Use {placeholders} for anything dynamic.

locales/en.json:

{
  "greeting": "Hello, {name}!",
  "balance": "You have {coins} coins.",
  "farewell": "Goodbye!"
}

locales/es.json:

{
  "greeting": "¡Hola, {name}!",
  "balance": "Tienes {coins} monedas."
}

Notice es.json is missing farewell — that's fine, and the next step handles it by falling back to English.

2. A tiny translation helper

i18n.js loads every file in locales/ once at startup and exposes a t(locale, key, vars) function:

const fs = require('fs');
const path = require('path');

const DEFAULT_LOCALE = 'en';
const locales = {};
for (const file of fs.readdirSync(path.join(__dirname, 'locales'))) {
  if (file.endsWith('.json')) {
    const code = file.slice(0, -5); // "en.json" -> "en"
    locales[code] = JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', file), 'utf8'));
  }
}

function t(locale, key, vars = {}) {
  const table = locales[locale] || locales[DEFAULT_LOCALE];   // unknown language -> English
  const str = table[key] ?? locales[DEFAULT_LOCALE][key] ?? key; // missing key -> English -> the key itself
  return str.replace(/\{(\w+)\}/g, (_, k) => (k in vars ? vars[k] : `{${k}}`));
}

module.exports = { t, locales, DEFAULT_LOCALE };

Two fallbacks make it robust: an unknown language falls back to English, and a missing key falls back to English (then to the key name, so you at least see which string is missing rather than a crash). That behavior is exactly what you want in production:

Call Result
t('es', 'greeting', { name: 'Sam' }) ¡Hola, Sam!
t('en', 'balance', { coins: 42 }) You have 42 coins.
t('es', 'farewell') Goodbye! (missing in es → English)
t('fr', 'greeting', { name: 'Sam' }) Hello, Sam! (no fr file → English)

3. Use it in a command

const { t } = require('./i18n');

// inside an interaction handler, with `locale` decided in step 4:
await interaction.reply(t(locale, 'greeting', { name: interaction.user.username }));

4. Where does locale come from?

Two good sources — pick one or combine them.

Discord already knows. Every interaction carries the user's client language as interaction.locale, and the server's setting as interaction.guildLocale. Discord uses codes like en-US, es-ES, pt-BR, so map the family to your files by stripping the region:

const locale = (interaction.locale || 'en').split('-')[0]; // "es-ES" -> "es"

That gives you localization with zero storage — the bot just adapts to each user.

Let server admins choose. For a fixed per-server language, store the choice. Reuse the SQLite pattern — a one-row-per-guild table:

const Database = require('better-sqlite3');
const db = new Database('bot.db');
db.exec(`CREATE TABLE IF NOT EXISTS guild_locale (guild_id TEXT PRIMARY KEY, locale TEXT NOT NULL)`);

const setLocale = db.prepare(
  `INSERT INTO guild_locale (guild_id, locale) VALUES (?, ?)
   ON CONFLICT(guild_id) DO UPDATE SET locale = excluded.locale`
);
const getLocale = db.prepare('SELECT locale FROM guild_locale WHERE guild_id = ?');

// /language es  ->
setLocale.run(interaction.guildId, 'es');

// when replying:
const row = getLocale.get(interaction.guildId);
const locale = row?.locale ?? 'en';

The stored choice survives restarts, because it's on disk — but a reinstall wipes a local .db, so guard it the same way as any bot data, and use migrations when the schema grows.

💡 Tip: You can localize the command names and descriptions too — the builders take setNameLocalizations / setDescriptionLocalizations, so /greet itself shows up translated in a Spanish client. Add that once the replies are done.

Verify it works

Set a guild to es with /language, restart the server, and run your greeting command from that server — it comes back in Spanish, and a key you only wrote in English still resolves (to English) instead of crashing. That combination — right language, graceful fallback, choice remembered after a restart — is the whole feature working.

Troubleshooting

  • Everything's still English — you're not passing locale into t(), or the locales/ folder didn't upload. Check the path and that the files are there.
  • {name} appears literally in the reply — you didn't pass that variable in the vars object.
  • A newly added language file isn't picked up — the loader reads locales/ once at startup; restart after adding a file.
  • The guild's choice is forgotten after a restart — you stored it in a plain variable, not the database. Use the table above.

Next steps

Was this guide helpful?