aslain.dev
0%
01 Hizmetler 02 Hakkımda 03 Projeler 04 Stack 05 Blog 06 İletişim
← Tüm makaleler Discord Bots

Discord Bot Multi-Language Support: Per-Server i18n

Once your bot grows and joins servers from different countries, Discord bot multi-language support stops being a nice-to-have and becomes a requirement. A Turkish community expects replies in Turkish, while a French server wants to see everything in French. The answer is an i18n (internationalization) setup where each server (guild) can pick its own language and every piece of bot text flows through a single source. In this guide we'll build a clean architecture with discord.js v14 that keeps translations in JSON files, switches language per server, and never hard-codes a single string.

Why an i18n setup? The cost of hard-coding text

Most bots start small and replies are written straight into the code: interaction.reply('You have no permission'). That approach collapses the moment you add a second language. Changing a single message means scanning the whole codebase, repeating the same text across dozens of files, and translation drift becomes unavoidable. A solid i18n setup fixes these problems at the root:

  • Single source of truth: every string lives under a key in the language files; the code only calls the key.
  • Per-server language: each guild picks its own language and the choice is stored in a database.
  • Easy to extend: adding a new language is just creating a new JSON file — you don't touch the code.
  • Translator-friendly: even someone who can't read code can edit the JSON file and add a translation.

The structure of translation files

Keeping a separate JSON file per language is the simplest and most readable approach. Nesting keys by topic preserves order as the file grows:

locales/
├─ tr.json
├─ en.json
├─ fr.json
└─ de.json
// locales/en.json
{
  "common": {
    "no_permission": "You don't have permission to use this command.",
    "error": "Something went wrong, please try again."
  },
  "ping": {
    "reply": "Pong! Latency: {ms}ms"
  },
  "ban": {
    "success": "{user} has been banned from the server."
  }
}
// locales/tr.json
{
  "common": {
    "no_permission": "Bu komutu kullanma yetkin yok.",
    "error": "Bir hata oluştu, lütfen tekrar dene."
  },
  "ping": {
    "reply": "Pong! Gecikme: {ms}ms"
  },
  "ban": {
    "success": "{user} sunucudan yasaklandı."
  }
}

Notice the placeholders like {ms} and {user} in the text. These get swapped with real values at runtime — so we can handle dynamic data without baking it into the translation.

The i18n core that loads the languages

When the bot boots, we read every JSON file in locales/ and gather them into an in-memory object. The real work lives in a t() function that takes a key and a language and returns the right text. It resolves the dotted key (ping.reply), fills in placeholders, and falls back to the default if the requested language is missing:

// i18n.js
const fs = require('node:fs');
const path = require('node:path');

const DEFAULT_LOCALE = 'en';
const locales = {};

// Load every language file into memory
const dir = path.join(__dirname, 'locales');
for (const file of fs.readdirSync(dir)) {
  if (!file.endsWith('.json')) continue;
  const code = file.replace('.json', '');
  locales[code] = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
}

// Walk a dotted key like "ping.reply" through the object
function resolve(obj, key) {
  return key.split('.').reduce((acc, part) => acc?.[part], obj);
}

function t(locale, key, vars = {}) {
  const lang = locales[locale] ? locale : DEFAULT_LOCALE;
  let text = resolve(locales[lang], key)
    ?? resolve(locales[DEFAULT_LOCALE], key)
    ?? key; // if nowhere to be found, show the key itself

  // replace placeholders like {ms} with values
  return text.replace(/\{(\w+)\}/g, (_, name) =>
    name in vars ? vars[name] : `{${name}}`
  );
}

module.exports = { t, locales };

The triple fallback here matters: first the requested language, then the default language, and in the worst case the key itself. So even if a translation is missing, the bot doesn't crash — that line simply shows up in English or as the key name.

Storing the server's language

Each guild should be able to pick its own language, and that choice must persist. In practice a small table mapping guild ID to language code is enough. SQLite is more than suitable for this; below I keep the idea simple with an in-memory Map, but in production you'd write this to a database:

// guildSettings.js — replace with SQLite/Mongo in production
const guildLocales = new Map();

function getGuildLocale(guildId) {
  return guildLocales.get(guildId) || 'en';
}

function setGuildLocale(guildId, locale) {
  guildLocales.set(guildId, locale);
}

module.exports = { getGuildLocale, setGuildLocale };

Discord's own interaction.guildLocale field gives the server's Discord interface language; you can use that as a default guess, but leaving the final decision to an admin is far more flexible.

Using it in commands: every reply goes through t()

All the pieces are in place now. When a command runs, we first look up that server's language and then produce every text through t(). Not a single Turkish or English sentence remains in the code:

// commands/ping.js
const { getGuildLocale } = require('../guildSettings');
const { t } = require('../i18n');

module.exports = {
  data: new SlashCommandBuilder()
    .setName('ping')
    .setDescription('Measures the bot latency'),

  async execute(interaction) {
    const locale = getGuildLocale(interaction.guildId);
    const ms = interaction.client.ws.ping;
    await interaction.reply(t(locale, 'ping.reply', { ms }));
  },
};

The /language command that switches the language

The final step is a command that lets admins pick the language. We offer the options with addStringOption and use setDefaultMemberPermissions so only authorized people can change it:

// commands/language.js
const { SlashCommandBuilder, PermissionFlagsBits } = require('discord.js');
const { setGuildLocale } = require('../guildSettings');
const { t } = require('../i18n');

module.exports = {
  data: new SlashCommandBuilder()
    .setName('language')
    .setDescription("Sets the server's bot language")
    .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
    .addStringOption(opt =>
      opt.setName('locale')
        .setDescription('Language')
        .setRequired(true)
        .addChoices(
          { name: 'Türkçe', value: 'tr' },
          { name: 'English', value: 'en' },
          { name: 'Français', value: 'fr' },
          { name: 'Deutsch', value: 'de' },
        )),

  async execute(interaction) {
    const locale = interaction.options.getString('locale');
    setGuildLocale(interaction.guildId, locale);
    await interaction.reply({
      content: t(locale, 'language.changed'),
      ephemeral: true,
    });
  },
};

Sending the confirmation with t(locale, ...) in the newly chosen language is a nice touch: the user immediately sees the change worked, in that language.

Frequently Asked Questions

Is it better to keep translations in a database instead of JSON?

Both have their place. JSON files are ideal for static text because they're tracked in version control (Git), easy to edit, and need no extra infrastructure at deploy time. If you want non-coders to edit translations from a panel, a database fits better. A common approach is to keep static UI text in JSON and user-generated content in a database.

Why do placeholders matter?

Languages build sentences differently; word order changes. If you split text into fragments and concatenate instead of using "{user} was banned", an order that's correct in one language breaks in another. Keeping the full sentence with placeholders under a single key lets each language build its own word order correctly.

What should I do about plural rules (1 member / 5 members)?

For simple projects, separate _one and _other keys are enough. For serious projects where plural rules get complex per language, a mature library like i18next handles this logic out of the box and saves you from writing your own.

Does your bot serve a global community? We can build an i18n architecture together that switches language per server, keeps translations clean, and is ready to grow. Get in touch with me and let's turn your bot into an assistant that speaks fluently in every language.

Bu kategorideki tüm yazılar →

Devamı için