Button types
Spraxium provides two button variants, each with its own decorator and use case. Static buttons have a fixed customId and fire a handler when clicked. Link buttons open a URL in the user's browser and do not fire any handler.
Both types are defined as decorated classes and rendered through ButtonService. The visual properties, label, style, emoji, and disabled state, are configured in the decorator and stored as metadata. The renderer reads this metadata at build time and produces the appropriate discord.js ButtonBuilder instance.
| Type | Decorator | Has handler | ID format |
|---|---|---|---|
| Static | @Button | Yes, via @ButtonHandler | Fixed customId string |
| Link | @LinkButton | No (opens URL) | No customId, uses url |
Static buttons
A static button is the simplest interactive component. You decorate a class with @Button, providing a customId and a label at minimum. The customId must be unique across your entire bot because the dispatcher matches incoming interactions by exact customId equality. The style field maps to Discord's button styles and defaults to 'primary' if omitted.
import { Button } from '@spraxium/components';
@Button({
customId: 'confirm_action',
label: 'Confirm',
style: 'success',
emoji: '✅',
})
export class ConfirmButton {}The handler class must be decorated with @ButtonHandler and pass the component class as its argument. This creates a metadata link that the dispatcher resolves during bootstrap. The handle method receives parameters through decorators: @Ctx() injects the raw ButtonInteraction, and @FlowContext() injects the context object when present.
Button configuration reference
| Field | Type | Required | Purpose |
|---|---|---|---|
| customId | string | Yes | Unique identifier used for interaction routing. Must be unique across the bot. |
| url | string | Yes (link) | URL opened when the link button is clicked. No handler fires. |
| label | string | Yes | Text displayed on the button face. |
| style | primary | secondary | success | danger | No | Visual style. Not applicable to link buttons. Defaults to primary. |
| emoji | string or custom emoji object | No | Unicode emoji string or custom emoji object displayed beside the label. |
| disabled | boolean | No | When true, the button is rendered as grayed-out and non-interactive. |
Link buttons
Link buttons open a URL in the user's browser without firing any handler on the bot side. They are useful for directing users to documentation, dashboards, or external tools. Because Discord requires a different button style for links, the @LinkButton decorator omits customId and style in favor of a url field.
import { LinkButton } from '@spraxium/components';
@LinkButton({
url: 'https://docs.example.com',
label: 'View Documentation',
emoji: '📖',
})
export class DocsLinkButton {}Link buttons can be mixed with static buttons in the same action row. You pass all button classes to ButtonService.build and the renderer produces the correct builder type for each one based on the metadata.
Rendering with ButtonService
ButtonService is the injectable service that transforms decorated classes into discord.js action rows. It provides two methods for different rendering scenarios. All methods read component metadata via reflection, so the service does not need explicit configuration beyond the decorated classes.
| Method | Signature | Purpose |
|---|---|---|
| build | build(input: Class | Class[], context?): ActionRowBuilder | Renders one or more static/link buttons into a single action row. Attaches context to all buttons when provided. |
| rowWithContext | rowWithContext(context, ...Classes): ActionRowBuilder | Convenience wrapper that calls build with context as the first argument and spreads classes. |
| buildMixedRow | buildMixedRow(entries: {button, items}[], context?): Promise<[ActionRowBuilder, string[]]> | Renders multiple dynamic buttons of different types into a single action row in one call. Returns the row and a flat array of all payload refs. Mix store-encoded and inline-encoded buttons freely. Automatically capped at five buttons. |
Discord limits action rows to five buttons. The service throws an error if you pass more than five classes to build. For layouts with more buttons, create multiple rows by calling build multiple times and pass each row individually in the components array.
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { ButtonService, ContextService } from '@spraxium/components';
import type { ChatInputCommandInteraction } from 'discord.js';
import { ConfirmButton } from '../components/buttons/confirm.button';
import { CancelButton } from '../components/buttons/cancel.button';
import { DocsLinkButton } from '../components/buttons/docs-link.button';
import { ModerationCommand } from './moderation.command';
@SlashCommandHandler(ModerationCommand)
export class ModerationCommandHandler {
constructor(
private readonly buttons: ButtonService,
private readonly context: ContextService,
) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const ctx = await this.context.create({ targetUserId: '123456' });
const actionRow = this.buttons.rowWithContext(
ctx,
ConfirmButton,
CancelButton,
);
const linkRow = this.buttons.build(DocsLinkButton);
await interaction.reply({
content: 'Review the moderation action below.',
components: [actionRow, linkRow],
});
}
}Button style reference
The style field accepts one of four named values that map to Discord's ButtonStyle enum. Each style controls the button's color and visual prominence. Link buttons always use the link style automatically and do not accept a style field.
| Name | Discord Style | Color | Use case |
|---|---|---|---|
| primary | ButtonStyle.Primary | Blurple | Default action, general purpose interactions. |
| secondary | ButtonStyle.Secondary | Gray | Lower-emphasis actions like cancel or dismiss. |
| success | ButtonStyle.Success | Green | Confirmation and positive outcome actions. |
| danger | ButtonStyle.Danger | Red | Destructive or irreversible actions like bans and deletes. |
Module registration
Only handler classes need to be registered, and they go in the handlers array of your feature module. Component classes decorated with @Button or @LinkButton are metadata-only and are never added to any module. The dispatcher reads their customId and configuration directly from the class reference stored inside each @ButtonHandler decorator, with no DI instantiation required.
import { Module } from '@spraxium/common';
import { ConfirmButtonHandler } from './components/confirm.handler';
import { CancelButtonHandler } from './components/cancel.handler';
import { UserBanButtonHandler } from './components/user-ban.handler';
@Module({
handlers: [ConfirmButtonHandler, CancelButtonHandler, UserBanButtonHandler],
})
export class ModerationModule {}Dynamic buttons
Static buttons use a fixed customId known at definition time. Dynamic buttons invert this: the button's label, style, and the data it carries are computed per render from an arbitrary payload object. The custom ID is generated automatically by the framework and encodes a reference to the stored payload. The handler retrieves the original payload from the store when the button is clicked.
The dynamic button system has three parts: the @DynamicButton class that defines the component and its rendering logic, the @DynamicButtonHandler class that handles the click, and the payload store that bridges the two.
Defining a dynamic button
A dynamic button class is decorated with @DynamicButton and must expose a static render(item) method that returns a ButtonRenderConfig. The baseId in the config is the prefix used when matching incoming interactions, and the framework appends the payload reference automatically. The payloadTtl is an optional TTL in seconds; payloads expire after this duration and any click after expiry will not reach the handler (defaults to 600 seconds, 10 minutes).
import { DynamicButton } from '@spraxium/components';
import type { ButtonRenderConfig } from '@spraxium/components';
interface Book {
id: string;
title: string;
}
@DynamicButton({ baseId: 'buy-book', payloadTtl: 300 })
export class BuyBookButton {
static render(book: Book): ButtonRenderConfig {
return {
label: `Buy "${book.title}"`,
style: 'success',
};
}
}Rendering a dynamic button
To render a single dynamic button into an action row, call ButtonService.build passing the component class and the payload object. The service calls render(item), stores the payload in the TTL-bounded store, and returns an ActionRowBuilder with the generated custom ID embedded.
import { SlashCommandHandler, Ctx } from '@spraxium/common';
import { ButtonService } from '@spraxium/components';
import type { ChatInputCommandInteraction } from 'discord.js';
import { BuyBookButton } from '../components/buttons/book-buy.button';
import { ShopCommand } from './shop.command';
@SlashCommandHandler(ShopCommand)
export class ShopCommandHandler {
constructor(private readonly buttons: ButtonService) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const book = await this.catalog.getBook('978-0-7432-7356-5');
const row = this.buttons.build(BuyBookButton, book);
await interaction.reply({
content: `Found **${book.title}**`,
components: [row],
});
}
}To combine multiple dynamic buttons of different types into the same row, use ButtonService.buildMixedRow. It accepts an array of { button, items } entries and returns a single ActionRowBuilder alongside a flat array of all payload refs - one per store-encoded button, in declaration order. Inline-encoded buttons produce no ref and are omitted from the refs array.
import { Ctx, SlashCommandHandler, SlashStringOption } from '@spraxium/common';
import { ButtonService } from '@spraxium/components';
import { type ChatInputCommandInteraction, MessageFlags } from 'discord.js';
import { OpenTicketCommand } from '../commands/open-ticket.command';
import { AssignTicketButton } from '../components/assign-ticket-button.component';
import { CloseTicketButton } from '../components/close-ticket-button.component';
import { TicketsRepository } from '../tickets.repository';
@SlashCommandHandler(OpenTicketCommand)
export class OpenTicketHandler {
constructor(
private readonly buttons: ButtonService,
private readonly tickets: TicketsRepository,
) {}
async handle(
@Ctx() interaction: ChatInputCommandInteraction,
@SlashStringOption('subject') subject: string,
): Promise<void> {
const ticket = await this.tickets.create({
subject,
openedBy: interaction.user.id,
});
// buildMixedRow renders both buttons into one row and collects all store refs.
// AssignTicketButton is store-encoded (payload persisted, ref tracked for revocation).
// CloseTicketButton is inline-encoded (ticket id encoded in the customId, no ref).
const [row, refs] = await this.buttons.buildMixedRow([
{ button: AssignTicketButton, items: [ticket] },
{ button: CloseTicketButton, items: [ticket] },
]);
// Persist each ref so it can be revoked when the ticket is closed externally.
await Promise.all(refs.map((ref) => this.tickets.addAssignRef(ticket.id, ref)));
await interaction.reply({
content: [
`## 🎫 Ticket \`${ticket.id}\``,
`**Subject:** ${ticket.subject}`,
`**Opened by:** <@${ticket.openedBy}>`,
].join('\n'),
components: [row],
flags: MessageFlags.Ephemeral,
});
}
}Parameter decorators in dynamic handlers
Dynamic button handlers support three parameter decorators in the handle() method.
| Decorator | Injects | Notes |
|---|---|---|
| @Ctx() | ButtonInteraction | The raw discord.js interaction object. |
| @ButtonPayload() | The payload object stored during render | Type the parameter as the same type passed to the render method. The framework deserializes the stored value. |
| @PayloadRef() | PayloadHandle | A handle for lifecycle control. Call await ref.consume() to permanently delete the payload so subsequent clicks get an expiry error instead of re-running the handler. |
@ButtonPayload() and @PayloadRef() are independent. You can use either or both in the same handler. Use @PayloadRef() alone when you only need to consume the payload and do not need the data, or combine both when you need the data and want to prevent re-execution.
Dynamic button configuration reference
| Field | Type | Required | Description |
|---|---|---|---|
| baseId | string | Yes | Prefix for generated custom IDs. Must be unique across all dynamic buttons in the bot. |
| payloadTtl | number (seconds) | No | Seconds until the stored payload expires. Clicks after expiry do not reach the handler. Defaults to 600 (10 minutes). |
Module registration
Dynamic button handlers follow the same registration rules as static button handlers. Register only the handler class in the handlers array of your module. The component class decorated with @DynamicButton is metadata-only and does not need to appear in any module.
import { Module } from '@spraxium/common';
import { BuyBookButtonHandler } from './components/book-buy.handler';
@Module({
handlers: [BuyBookButtonHandler],
})
export class ShopModule {}@DynamicStringSelect works the same way for select menus: define a class with @DynamicStringSelect, expose a static render(items) method returning a SelectRenderConfig, and write a handler with @DynamicSelectHandler. Use @SelectPayload() instead of @ButtonPayload() to inject the stored payload.
Inline params encoding
By default, dynamic buttons store their payload in a TTL-bounded server-side store and embed only an opaque reference in the custom ID. This is the right choice for large or complex objects.
For small, primitive data - like a single database record ID - you can use encoding: 'inline' instead. The framework encodes the params directly into the custom ID string, removing any dependency on the payload store and the TTL entirely. The data lives in the Discord message for as long as the message exists.
Discord enforces a hard limit of 100 characters on custom IDs. The framework
measures the encoded string at render time and throws
DynamicButtonInlinePayloadTooLargeError if the result would exceed this limit.
Keep inline params small: one or two short IDs or flags. Use store-based encoding
for anything larger.
import { DynamicButton } from '@spraxium/components';
import type { ButtonRenderConfig } from '@spraxium/components';
interface Suggestion {
id: number;
userId: string;
}
@DynamicButton({ baseId: 'approve-suggestion', encoding: 'inline' })
export class ApproveSuggestionButton {
static render(s: Suggestion): ButtonRenderConfig {
return {
label: 'Approve',
style: 'success',
params: { id: s.id },
};
}
}Notice that @ButtonParams() replaces @ButtonPayload() for inline-encoded buttons. Using @ButtonPayload() or @PayloadRef() on an inline handler causes a bootstrap error. The type of params is inferred from the params field returned by render(), so TypeScript knows the shape without any manual casting.
The encoded custom ID for the example above looks like approve-suggestion~id=42. Values are URL-encoded to handle special characters. Numbers and booleans are cast back to their original types when decoded.
Revoking payloads externally
When a dynamic button payload lives in the store, the handler can consume it with ref.consume() after handling the click. But some flows require revoking a payload before any click happens - for example, closing a ticket via a slash command should immediately invalidate the approve/reject buttons in the admin channel.
Inject ButtonPayloadService to revoke one or more payload refs from any service, command handler, or listener.
Storing refs at render time
ButtonService.buildMixedRow returns a tuple [ActionRowBuilder, string[]]. The refs array contains one entry per store-encoded button in declaration order. Inline-encoded buttons produce no ref. Store each ref in your database so it can be revoked later:
interface TicketRecord {
id: string;
subject: string;
openedBy: string;
assignRefs: string[]; // payload refs for all store-encoded buttons
status: 'open' | 'closed';
}
export class TicketsRepository {
async create(data: Pick<TicketRecord, 'subject' | 'openedBy'>): Promise<TicketRecord> {
// INSERT INTO tickets (...)
return { id: 'ticket-uuid', assignRefs: [], status: 'open', ...data };
}
async addAssignRef(ticketId: string, ref: string): Promise<void> {
// UPDATE tickets SET assign_refs = JSON_ARRAY_APPEND(assign_refs, '$', ?) WHERE id = ?
}
async findOpenById(ticketId: string): Promise<TicketRecord | null> {
// SELECT ... WHERE id = ? AND status = 'open'
return null;
}
}Then, when the ticket is closed from outside a handler, load the record and revoke all refs:
const ticket = await this.tickets.findOpenById(ticketId);
if (ticket?.assignRefs.length) {
await this.payloads.revokeMany(ticket.assignRefs);
}Revoking when the flow ends externally
import { SlashCommandHandler, Ctx, SlashStringOption } from '@spraxium/common';
import { ButtonPayloadService } from '@spraxium/components';
import { type ChatInputCommandInteraction, MessageFlags } from 'discord.js';
import { CloseTicketCommand } from '../commands/close-ticket.command';
import { TicketsRepository } from '../tickets.repository';
@SlashCommandHandler(CloseTicketCommand)
export class CloseTicketHandler {
constructor(
private readonly payloads: ButtonPayloadService,
private readonly tickets: TicketsRepository,
) {}
async handle(
@Ctx() interaction: ChatInputCommandInteraction,
@SlashStringOption('ticket-id') ticketId: string,
): Promise<void> {
const ticket = await this.tickets.findOpenById(ticketId);
if (!ticket) {
return interaction.reply({ content: 'Ticket not found or already closed.', flags: MessageFlags.Ephemeral });
}
// Revoke all store-encoded button refs - any subsequent click will receive
// the configured expired message instead of reaching the handler.
if (ticket.assignRefs.length) {
await this.payloads.revokeMany(ticket.assignRefs);
}
await this.tickets.close(ticketId);
await interaction.reply({ content: `Ticket \`${ticketId}\` closed.`, flags: MessageFlags.Ephemeral });
}
}ButtonPayloadService API reference
| Method | Signature | Purpose |
|---|---|---|
| revoke | revoke(ref: string): Promise<void> | Deletes a single payload from the store. After revocation, any click on a button carrying this ref receives the expired message. Resolves silently if the ref no longer exists. |
| revokeMany | revokeMany(refs: string[]): Promise<void> | Batch revoke. Useful for cleaning up multiple buttons at once, e.g. all action buttons on a resolved ticket. Missing refs are silently ignored. |
ButtonPayloadService is globally available through the components module.
You do not need any additional module imports to inject it - just add it to
your constructor like any other service.