Banner by Leonardo Toshiro Okubo on Unsplash
If you want to make a Discord bot that speaks multiple languages, the question is inevitable: "HOW?"
I’ve done it the hard way - my own JSON loader, manual locale passing, require("./en.json"), string lookups everywhere. It worked. Barely.
But what if you didn’t have to manage the locale yourself every single time you needed a translation?
You write:
m.greeting({ user: "Alex" })
And it just works - in the user’s language, with correct grammar, plurals, and more.
Let’s set it up!
Paraglide JS
Paraglide is a lightweight, type-safe internationalization (i18n) library for JavaScript and TypeScript projects, designed to make multi-language support effortless and scalable. Built by the inlang team, it eliminates the boilerplate of traditional translation systems by generating optimized code from your translation files at build time. No more manual locale management, runtime JSON parsing, or error-prone string interpolation—Paraglide handles it all with zero runtime overhead.
Unlike my old-school JSON-based translation handler (which basically boiled down to require("path/to/translations/en.json") and manual locale juggling every time), Paraglide lets you focus on your bot's logic. You define translations once in simple .json files, run a quick build step, and get fully typed functions for every message. Want to fetch a greeting in French? Just call m.greeting({ user: 'Pierre' }) - it (kind of) auto-detects the locale from your Discord user's settings and returns the translated string. Types ensure no typos or missing params, catching errors at compile time.
Inlang Message Format
Inlang's message format even provides a very nice solution for built-in variants, pluralization and more!
See their docs for more info.
Using it, I can easily do this:
"greetUsers": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Say hello to {count} user!",
"countPlural=other": "Say hello to {count} users!"
}
}
],
Paraglide Setup
Before we can really start, we need to initialize paraglide.
In my example I have the following project structure:
project/
└── src/
├── commands/
│ └── someCommand.ts
├── components/
│ └── someComponent.ts
├── events/
│ └── messageCreate/
│ └── index.ts
└── index.ts
I have my own solution for loading all commands, components and events, but that isn't important here. More on that later.
Now we need to run the init wizard.
# npm
npx @inlang/paraglide-js@latest init
# pnpm
pnpx @inlang/paraglide-js@latest init
# bun (I use bun in my project, but that doesn't matter)
bunx @inlang/paraglide-js@latest init
It will ask you some stuff and you should do the following:
- Where should the compiled files be placed?
./src/paraglide(default) - Do you want to set up machine translations? If you want, yes. It will add a script in your package.json for that.
This will create
-
project/messagesfor translations- Locale files for messages look like
en.jsonorde-DE.jsonin this directory. - This has the message
example_messagein it by default
- Locale files for messages look like
-
project/project.inlangfor settings -
project/src/paraglide/the directory for compiled translations ("messages") - A script to compile translations for runtime called
build(you can change that if you want - I changed it tocompile-translations - A script to run machine translations:
machine-translateMore on that later.
DON'T FORGET TO RUN npm i AFTER INIT! (or whatever your package manager is)
Discord.js Bot Setup
There is no big difference for handling interactions and events, however we need to add a sort of middleware.
Paraglide can't detect the locale just by itself. But it supports AsyncLocalStorage which we will use here. AsyncLocalStorage enables you to store data (like primitives or objects) that remains accessible throughout an asynchronous call stack, automatically propagating the context across awaits and callbacks without manual passing. It’s ideal for tracking per-request state, such as locales, in async-heavy environments like Discord bots.
// project/src/index.ts
import { type ClientEvents } from "discord.js";
import { baseLocale, locales, overwriteGetLocale, type Locale } from "./paraglide/runtime.js";
const localeStorage = new AsyncLocalStorage<Locale>({ name: "locales" });
// We need to determine the locale when calling the "get message" function in an interaction
overwriteGetLocale(() => {
return localeStorage.getStore() ?? baseLocale;
});
// ...
// Wrapper for an interaction handler
function interactionMiddleware(
interaction: ClientEvents["interactionCreate"][0],
next: (interaction: ClientEvents["interactionCreate"][0]) => Promise<void>,
) {
const locale = interaction.locale.slice(0, 2) as Locale; // simplify discord's locale to something more basic (this is not needed, you can also use discord's locale-system which has more languages but is more of a headache to localize)
const validLocale = locales.includes(locale) ? locale : baseLocale;
return localeStorage.run(validLocale, () => next(interaction));
}
It is important to note, when loading interaction handlers from their respective file, we need to wrap them in interactionMiddleware to get it working.
// project/src/index.ts
// Very basic example, you want to integrate this into your own loading logic!
import { execute } from "./commands/someCommand.js"
client.on("interactionCreate", async (interaction) => {
await interactionMiddleware(interaction, execute)
});
Localizing event handlers is also possible, but requires a more complex setup. More on that later.
Now you can use paraglide's compiled messages in your command handler!
// project/src/commands/someCommand.js
import { type ChatInputCommandInteraction } from "discord.js";
import { m } from "../paraglide/messages.js";
// Command declaration here...
export async function execute(interaction: ChatInputCommandInteraction) {
await interaction.reply({
flags: 64, // Ephemeral
content: m.example_message({ username: interaction.user.username })
});
}
Now you're good to go! You have localized your Discord bot interactions!
Localizing Event Handlers
You can apply the same pattern to event handlers (e.g., messageCreate, guildMemberAdd), but you’ll need to determine the locale manually since most events don’t include interaction.locale.
Strategies:
-
In-guild events: Use
guild.preferredLocale(if available). - User-specific events: Store user language preferences in your database and cache it to avoid unnecessary latency and database queries.
-
Fallback: Default to
baseLocale.
Example Middleware for messageCreate:
// src/middleware/eventLocale.ts
import { AsyncLocalStorage } from "async_hooks";
import { baseLocale, locales, type Locale } from "../paraglide/runtime.js";
export const localeStorage = new AsyncLocalStorage<Locale>();
// Reuse the same instance everywhere
export function withEventLocale<T extends any[]>(
event: any,
handler: (...args: T) => Promise<void>,
getLocaleFromEvent: (event: any) => Locale | undefined
) {
const locale = getLocaleFromEvent(event);
const validLocale = locale && locales.includes(locale) ? locale : baseLocale;
return localeStorage.run(validLocale, () => handler(event));
}
It is important, that the same AsyncLocalStorage instance is used for all middlewares because we have overridden the get-logic before!
Then in your event-loading-logic:
// wherever you're loading your events
// Very basic implementation - adjust for your own loading-logic
import { default as handleMessage } from "./events/messageCreate/index.js";
import { withEventLocale } from "./middleware/eventLocale.ts";
import { baseLocale } from "./paraglide/runtime.js";
client.on("messageCreate", (message) =>
withEventLocale(message, handleMessage, (msg) => {
if (msg.guild?.preferredLocale) {
return msg.guild.preferredLocale.slice(0, 2) as Locale;
}
// Optional: check user DB, etc.
return baseLocale;
})
);
Further Reading
Translation Compilation
Paraglide compiles your messages to something useful in project/src/paraglide.
You need to incorporate the compilation-step in your workflow somehow.
If using Docker, you can put bun run compile-translations for example.
When using a script like start in your package.json, you need to modify it by adding the compilation script before the actual run-step. Example for bun:
{
"scripts": {
"start": "bun run compile-translations && bun run src/index.ts",
"compile-translations": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide"
}
}
Machine Translations
Machine Translations are there to help you quickly translate your messages. They are not 100% accurate and might have some formatting issues. However, it gets the job done.
When initializing paraglide, it adds the inlang CLI to your project as well as this script:
"machine-translate": "inlang machine translate --project project.inlang"
You can run this with your package manager of choice. With bun: bun machine-translate.
This will
- Pull the
localesfrom yourproject/project.inlang/settings.json - Translate every message that is missing in other locales from your
baseLocaleinto all other locales This will not overwrite existing translated messages and only translate that, where no translation has been found.
Links
- Paraglide Docs (if the link isn't valid, go here and look for Paraglide JS)
- Discord.js Guide
Top comments (1)
Spin up Paraglide.js with inlang, add a compile step to your start script, and wire a single AsyncLocalStorage to override Paraglide’s getLocale, pulling from interaction.locale-boom, localized replies. Normalize Discord locale codes to your typed Locale union and stick to a clean fallback chain, if a key’s missing, just render with base and keep cruising. In CI/CD, always compile translations, surface missing/obsolete keys, bust Docker cache to dodge stale builds, prefer ESM on Node 20/22 or Bun 1.1+, migrate from JSON/i18next via a thin t() wrapper, re-register commands only when a hash changes, and consider shipping a tiny template repo so others can clone your setup-easy win.