Why a structured i18n system matters
Most bots start with hardcoded English strings scattered across handler files, and that works fine for the first few hundred users. The problem surfaces when the community grows to include guilds in Brazil, Spain, Japan, or Korea: the only path to supporting those users is manually tracing every string through the codebase and wrapping each one in conditional logic. That approach does not scale, breaks across refactors, and mixes presentation concerns directly into command logic.
Spraxium's i18n module solves this with a centralized registry of translated strings loaded once at boot, a resolution pipeline that automatically picks the right locale for every interaction, and helpers that wire slash command localizations directly through the command decorator. Writing an internationalized bot looks almost identical to writing a monolingual one, because the complexity is absorbed by the framework rather than scattered across handler methods.
Localization is not just about text replacement. It is about respecting that users have a preferred language, that Discord itself has a locale system affecting how commands appear in the UI, and that pluralization rules differ significantly across languages. The module handles all three concerns in one cohesive system so you rarely need to think about the mechanics while writing handlers.
How locale resolution works
When a handler calls tUser, the service resolves the locale for that user through a three-tier priority chain: a saved user preference beats a saved guild preference, and a saved guild preference beats the configured default locale. This ordering is the correct model for Discord bots because it respects individual choices first, then community-level settings, and falls back to the bot-wide default only when no preference has been set at all.
User and guild preferences are stored in the active I18nStore backend and survive bot restarts as long as the store backend is persistent. The built-in MemoryStore works for local development and bots that do not need persistent preferences, while production bots should use a database-backed store such as those illustrated in the Store API page.
Slash command localizations follow a different path: when the bot registers commands with Discord's REST API, names and descriptions can have per-locale variants that Discord renders natively in the command picker. The i18n module wires those localizations automatically during the boot sequence, so declaring an i18n field on a slash decorator is all that is needed to produce a fully localized command entry in Discord's UI.
Configuring the module
import { defineI18n } from '@spraxium/i18n';
export const i18nConfig = defineI18n({
defaultLocale: 'en-US',
references: [
{ name: 'commands', path: './locales/commands' },
{ name: 'messages', path: './locales/messages' },
{ name: 'errors', path: './locales/errors' },
],
});Add the config object to the plugins array in your Spraxium config. The module is self-contained
and registers its own I18nLifecycle, which runs at boot automatically before any slash commands
are registered.
| Field | Type | Purpose |
|---|---|---|
| defaultLocale | string | Fallback locale used when no user or guild preference is set |
| references | Array | Named pointers to locale directories on disk |
| pluralRules | Record | Optional custom plural functions per locale, overriding Intl.PluralRules |
The I18nService API
Inject I18nService into any handler or provider. It exposes both synchronous and asynchronous translation methods to cover every usage pattern your bot needs.
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { I18nService } from '@spraxium/i18n';
import type { ChatInputCommandInteraction } from 'discord.js';
import { PingCommand } from '../commands/ping.command';
@SlashCommandHandler(PingCommand)
export class PingHandler {
constructor(private readonly i18n: I18nService) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const locale = interaction.locale;
const message = this.i18n.t('commands.ping.response', locale);
await interaction.reply(message);
}
}| Method | Async | What it does |
|---|---|---|
| t(key, locale, vars?) | No | Translate key for an explicit locale with optional variable substitution |
| tp(key, locale, count, vars?) | No | Translate with pluralization for an explicit locale |
| tUser(userId, key, vars?, guildId?) | Yes | Translate after resolving locale via user and guild preference chain |
| tpUser(userId, key, count, vars?, guildId?) | Yes | Pluralized translation after resolving locale via preference chain |
| setUserLocale(userId, locale) | Yes | Persist a locale preference for a user |
| setGuildLocale(guildId, locale) | Yes | Persist a locale preference for a guild |
| getUserLocale(userId) | Yes | Retrieve the user's stored locale or the default |
| getGuildLocale(guildId) | Yes | Retrieve the guild's stored locale or the default |
| resolveLocale(userId, guildId?) | Yes | Run the full resolution chain and return the winning locale |