Why localized builders exist
When you define components with decorators such as @Button, @StringSelect, or @ModalComponent, the visible text usually lives in decorator metadata: labels, placeholders, option descriptions, titles, and field names. In a single-language bot that is enough. In a localized bot, that quickly becomes repetitive because every handler would otherwise need to resolve the same strings manually before sending the component.
The localized builders remove that repetition. They read the i18n metadata declared on the component class, resolve each key for the requested locale through I18nService, and return a ready-to-send discord.js builder. The handler stays focused on interaction flow instead of translation plumbing.
The usual workflow is simple:
- Declare fallback text and i18n keys in the component decorator.
- Resolve the target locale in the handler.
- Call the matching localized builder.
- Send the returned row, modal, embed, or V2 payload.
Declaring i18n keys on components
Add an i18n object to any component decorator that supports it. Each entry points to the translation key that should override the static fallback text at runtime. The static value is still important: it becomes the fallback shown when the locale file does not define that key.
This fallback-first model is what makes gradual translation practical. You can translate one component at a time without breaking the rest of the UI.
import { Button } from '@spraxium/components';
@Button({
customId: 'confirm',
label: 'Confirm',
style: 'success',
i18n: {
label: 'buttons.confirm.label',
},
})
export class ConfirmButton {}import { SelectOption, StringSelect } from '@spraxium/components';
@StringSelect({
customId: 'topic_select',
placeholder: 'Choose a topic…',
i18n: { placeholder: 'select.topic.placeholder' },
})
@SelectOption({
label: 'Development',
value: 'dev',
description: 'Code and architecture topics',
i18n: {
label: 'select.topic.options.dev.label',
description: 'select.topic.options.dev.description',
},
})
@SelectOption({
label: 'Operations',
value: 'ops',
description: 'Deployment and infra topics',
i18n: {
label: 'select.topic.options.ops.label',
description: 'select.topic.options.ops.description',
},
})
export class TopicSelect {}buildLocalizedButton
Use buildLocalizedButton when the only thing your handler needs is a localized action row made of static button classes. It resolves labels and emoji-related keys declared on each button and returns a single action row ready to send.
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { buildLocalizedButton } from '@spraxium/i18n';
import type { I18nService } from '@spraxium/i18n';
import type { ChatInputCommandInteraction } from 'discord.js';
import { ActionsCommand } from '../commands/actions.command';
import { ConfirmButton } from '../components/confirm-button.component';
import { CancelButton } from '../components/cancel-button.component';
@SlashCommandHandler(ActionsCommand)
export class ActionsCommandHandler {
constructor(private readonly i18n: I18nService) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const locale = await this.i18n.getUserLocale(interaction.user.id);
const row = buildLocalizedButton({ input: [ConfirmButton, CancelButton], locale });
await interaction.reply({ content: 'Choose an action:', components: [row] });
}
}Signature: buildLocalizedButton(options): ActionRowBuilder<ButtonBuilder>
| Option | Type | Description |
|---|---|---|
| input | Class or Class[] | One button class or an array of button classes to place in the row. |
| locale | string | Target locale, such as pt-BR. If a key is missing, the static decorator label is used. |
If you already have runtime-specific button rendering logic, keep using the dynamic component APIs. The localized builder is best when the structure is static and only the visible text changes by locale.
buildLocalizedSelect
Use buildLocalizedSelect for string selects whose placeholder, option labels, and option descriptions come from translation files. The function is async because select metadata may need asynchronous locale resolution.
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { buildLocalizedSelect } from '@spraxium/i18n';
import type { I18nService } from '@spraxium/i18n';
import type { ChatInputCommandInteraction } from 'discord.js';
import { TopicCommand } from '../commands/topic.command';
import { TopicSelect } from '../components/topic-select.component';
@SlashCommandHandler(TopicCommand)
export class TopicCommandHandler {
constructor(private readonly i18n: I18nService) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const locale = await this.i18n.getUserLocale(interaction.user.id);
const row = await buildLocalizedSelect({ selectClass: TopicSelect, locale });
await interaction.reply({ content: 'Pick a topic:', components: [row] });
}
}Signature: buildLocalizedSelect(options): Promise<ActionRowBuilder<AnySelectBuilder>>
| Option | Type | Description |
|---|---|---|
| selectClass | Class | The component class decorated with @StringSelect. |
| locale | string | The locale whose placeholder and option texts should be resolved. |
This builder is a good fit for stable option sets such as topic lists, onboarding choices, or settings menus. If the option list itself changes at runtime, combine localization with the dynamic select APIs instead of forcing all possibilities into static decorator metadata.
buildLocalizedModal
Use buildLocalizedModal when the modal structure is fixed but the title, labels, and placeholders must change per locale. The modal title and custom ID come from the @ModalComponent metadata, while field labels and placeholders are resolved from the field decorators.
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { buildLocalizedModal } from '@spraxium/i18n';
import type { I18nService } from '@spraxium/i18n';
import type { ChatInputCommandInteraction } from 'discord.js';
import { FeedbackCommand } from '../commands/feedback.command';
import { FeedbackModal } from '../components/feedback-modal.component';
@SlashCommandHandler(FeedbackCommand)
export class FeedbackCommandHandler {
constructor(private readonly i18n: I18nService) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const locale = await this.i18n.getUserLocale(interaction.user.id);
await interaction.showModal(buildLocalizedModal({ modalClass: FeedbackModal, locale }));
}
}Signature: buildLocalizedModal(options): ModalBuilder
| Option | Type | Description |
|---|---|---|
| modalClass | Class | The component class decorated with @ModalComponent. |
| locale | string | The locale used to resolve the modal title, labels, and placeholders. |
This keeps handlers especially clean in feedback flows, forms, onboarding steps, and any modal opened from buttons or slash commands.
buildLocalizedEmbed
Use buildLocalizedEmbed when your embed shape is stable and only the visible text changes by locale or template data. It resolves title, description, field names, and field values at call time, then interpolates any variables supplied through data.
import { Embed, EmbedField } from '@spraxium/components';
@Embed({
color: 0x5865f2,
i18n: { title: 'embeds.stats.title' },
})
@EmbedField({
name: 'Guilds',
value: '{{guilds}}',
inline: true,
i18n: { name: 'embeds.stats.fields.guilds' },
})
export class StatsEmbed {}import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { buildLocalizedEmbed } from '@spraxium/i18n';
import type { I18nService } from '@spraxium/i18n';
import type { ChatInputCommandInteraction } from 'discord.js';
import { StatsCommand } from '../commands/stats.command';
import { StatsEmbed } from '../components/stats-embed.component';
@SlashCommandHandler(StatsCommand)
export class StatsCommandHandler {
constructor(private readonly i18n: I18nService) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const locale = await this.i18n.getUserLocale(interaction.user.id);
const embed = buildLocalizedEmbed({
embedClass: StatsEmbed,
locale,
data: { guilds: String(interaction.client.guilds.cache.size) },
});
await interaction.reply({ embeds: [embed] });
}
}Signature: buildLocalizedEmbed(options): EmbedBuilder
| Option | Type | Description |
|---|---|---|
| embedClass | Class | The component class decorated with @Embed. |
| locale | string | The locale used for title, description, and field text resolution. |
| data | Record<string, string> | Optional variables used to fill placeholders such as or . |
It is especially useful for status cards, dashboards, and summary embeds where the same layout is reused across locales and only the content strings vary.
buildLocalizedV2
Use buildLocalizedV2 when your UI is built with V2 containers and you want the same locale-driven workflow used by the other builders. It resolves i18n keys on text blocks, sections, and embedded components, then delegates rendering to V2Service.
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import type { V2Service } from '@spraxium/components';
import { buildLocalizedV2 } from '@spraxium/i18n';
import type { I18nService } from '@spraxium/i18n';
import { MessageFlags, type ChatInputCommandInteraction } from 'discord.js';
import { ProfileCommand } from '../commands/profile.command';
import { ProfileContainer } from '../schemas/profile.container';
@SlashCommandHandler(ProfileCommand)
export class ProfileCommandHandler {
constructor(
private readonly i18n: I18nService,
private readonly v2: V2Service,
) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const locale = await this.i18n.getUserLocale(interaction.user.id);
const data = { username: interaction.user.username };
const reply = buildLocalizedV2({
containerClass: ProfileContainer,
locale,
v2Service: this.v2,
data,
});
await interaction.reply({ ...reply, flags: reply.flags | MessageFlags.Ephemeral });
}
}Signature: buildLocalizedV2(options): V2ReplyPayload
| Option | Type | Description |
|---|---|---|
| containerClass | Class | The class decorated with @V2Container. |
| locale | string | The locale whose strings should be resolved across the container tree. |
| v2Service | V2Service | The injected renderer used to build the final V2 payload. |
| data | Record<string, string> | Optional variables used for placeholder interpolation inside localized strings. |
Reach for this builder when you want one V2 schema to serve multiple locales without duplicating container classes.
Builder reference
| Function | Returns | Async | Best fit |
|---|---|---|---|
| buildLocalizedButton | ActionRowBuilder<ButtonBuilder> | No | Static button rows whose text changes by locale. |
| buildLocalizedSelect | ActionRowBuilder<AnySelectBuilder> | Yes | Localized string select menus with static option metadata. |
| buildLocalizedModal | ModalBuilder | No | Forms whose labels and placeholders vary by locale. |
| buildLocalizedEmbed | EmbedBuilder | No | Reusable embed layouts with translated text and interpolated values. |
| buildLocalizedV2 | V2ReplyPayload | No | Localized V2 containers and reply payloads. |
All builders fall back to the static value defined in the decorator when the requested locale does not contain the key. Partially translated bots keep working, and missing keys degrade to the default text instead of breaking the interaction flow.
Practical guidance
Use the localized builders when the structure of the UI is stable and the text is what changes. If both structure and content vary at runtime, keep the localized builder for the translated pieces and combine it with the dynamic component APIs where needed.
That split usually keeps code easier to maintain:
- Decorators define the stable component shape.
- Locale files define the visible text.
- Handlers decide only when to build and send the component.