Adding a Discord bot button turns your messages from walls of text into a real interface: the user clicks, the bot reacts instantly. With discord.js, buttons, dropdown (string select) menus and the ActionRow components that carry them let you build interactive flows like role pickers, pagination, confirmation dialogs or support-ticket panels. In this guide you'll build the components from scratch, listen for clicks correctly after sending them, and learn how to avoid the most common mistakes.
The component model: ActionRow, button and select
In Discord, interactive elements are never attached directly to a message — they sit inside rows. A single message can hold at most 5 ActionRows. Each row carries either up to five buttons or one select menu; you cannot mix the two in the same row. In discord.js v14 you build these structures with builder classes:
ButtonBuilder— a clickable button with a style, label andcustomId.StringSelectMenuBuilder— a dropdown with fixed options.ActionRowBuilder— the container that groups components.
Button styles come from the ButtonStyle enum: Primary (blue), Secondary (grey), Success (green), Danger (red) and Link. Link buttons open a URL and take no customId; the others produce interactions.
Creating a Discord bot button
The example below adds two buttons to a slash command. The customId field is the key piece: it's the unique identifier that lets you tell click events apart, and it can be up to 100 characters long.
const {
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
} = require('discord.js');
const confirm = new ButtonBuilder()
.setCustomId('order_confirm')
.setLabel('Confirm')
.setStyle(ButtonStyle.Success);
const cancel = new ButtonBuilder()
.setCustomId('order_cancel')
.setLabel('Cancel')
.setStyle(ButtonStyle.Danger);
const row = new ActionRowBuilder().addComponents(confirm, cancel);
await interaction.reply({
content: 'Do you want to confirm the order?',
components: [row],
});
The message now shows two buttons. But the buttons are still "dead": for anything to happen on click, you need to set up a separate listener.
Adding a string select menu
A dropdown is tidier than buttons when you want to offer the user several options. Each option has a visible label and a value that comes back to you. Optionally, you can enable multi-select with setMinValues and setMaxValues.
const { StringSelectMenuBuilder } = require('discord.js');
const menu = new StringSelectMenuBuilder()
.setCustomId('role_select')
.setPlaceholder('Pick a role')
.addOptions(
{ label: 'Developer', value: 'dev', emoji: '💻' },
{ label: 'Designer', value: 'design', emoji: '🎨' },
{ label: 'Community', value: 'community', emoji: '🤝' },
);
const menuRow = new ActionRowBuilder().addComponents(menu);
await interaction.reply({ components: [menuRow] });
A select menu fills a whole row on its own; you cannot put a button on the same row. If you want several menus or button groups, create separate ActionRowBuilder objects and add them all to the components array.
Listening for interactions: interactionCreate and collectors
There are two ways to catch clicks. For permanent, always-on buttons, use the bot's main interactionCreate event and branch on the customId:
client.on('interactionCreate', async (interaction) => {
if (interaction.isButton()) {
if (interaction.customId === 'order_confirm') {
await interaction.reply({ content: 'Order confirmed ✅', ephemeral: true });
}
}
if (interaction.isStringSelectMenu() && interaction.customId === 'role_select') {
const choice = interaction.values[0];
await interaction.reply({ content: `You picked: ${choice}`, ephemeral: true });
}
});
For temporary flows tied to a specific message (pagination, or a one-off confirmation), createMessageComponentCollector is more practical. A collector closes after a set time and only listens to that message's components:
const reply = await interaction.fetchReply();
const collector = reply.createMessageComponentCollector({
time: 60_000,
});
collector.on('collect', async (i) => {
if (i.user.id !== interaction.user.id) {
return i.reply({ content: 'This menu is not for you.', ephemeral: true });
}
await i.update({ content: `Choice: ${i.customId}`, components: [] });
});
collector.on('end', () => {
reply.edit({ components: [] }).catch(() => {});
});
Always respond to every interaction
Discord expects a response to an interaction within 3 seconds; otherwise the user sees an "This interaction failed" error. If you can't reply that quickly (a database query, an external API call, etc.), call deferUpdate() or deferReply() first and continue with editReply(). Tell the response methods apart like this:
reply()— sends a brand-new response.update()— edits the original message the button was on (ideal for closing menus).deferUpdate()/deferReply()— a "thinking" state that buys time for long operations.
The ephemeral: true option shows the response only to the person who clicked, keeping the channel clean for role panels and error messages.
Frequently Asked Questions
Why does customId matter so much?
Because the customId is the only way you know which button or menu was clicked. Keep it unique and readable; if needed you can embed data (a user or message id) like ticket_close_12345 and parse it at click time.
How many buttons can a message have?
Up to 5 ActionRows per message and up to 5 buttons per button row, so you can reach 25 buttons in total. A select menu, on the other hand, fills a row by itself.
Do old buttons stop working after the bot restarts?
Collectors are tied to the running bot, so they're lost on restart. If you want permanent buttons, use a global interactionCreate listener with customId-based routing instead of a collector; that way clicks are still handled every time the bot starts.
Need an interactive Discord bot? I can put together a clean, maintainable setup for role panels, ticket systems or custom select-menu flows. Get in touch and let's talk about your project.