Only show guides for your app
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 |
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.
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) |
const { t } = require('./i18n');
// inside an interaction handler, with `locale` decided in step 4:
await interaction.reply(t(locale, 'greeting', { name: interaction.user.username }));
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.
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.
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.locales/ once at startup; restart after adding a file.