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/greetitself 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
localeintot(), or thelocales/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 thevarsobject.- 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.