NapNodes

← Discord bot guides

Discord bots · updated 31 October 2026

Buttons and select menus in your Discord bot (discord.js and discord.py)

Buttons and select menus are what make a bot usable without typing commands: turning pages, picking a topic, confirming something. Here's a complete bot in discord.js and in discord.py with a multi-page /rules and a /help with a menu, how to reply before Discord says “This interaction failed”, how to keep buttons working after the bot restarts, and the limits of each one.

How buttons and menus work

Buttons and menus go in rows under the message: each row holds up to 5 buttons or a single menu, never both, and a normal message allows up to 5 rows. Each one has a custom_id (1 to 100 characters) that you choose: when someone presses it, Discord sends the bot an interaction with that custom_id, and that's how it knows what was pressed.

There are five button styles you can use: blue (Primary), grey (Secondary), green (Success), red (Danger) and the link one (Link), which opens an address. The link button has no custom_id and doesn't notify the bot: there's nothing to code for it.

The text menu (string select) holds up to 25 options, each with its text (label), its value (value, what the bot receives) and an optional description. With min_values and max_values (up to 25) people can pick more than one. If the buttons go on a message with an embed, how to build the embed is in how to send embeds with your bot; and if you don't have slash commands yet, see slash commands for your bot.

Rather not set it up yourself? Discord bot hosting from €1.50 a month, no contract and a full refund within 72 hours. Or create a free Discord bot, no card needed.

In discord.js

With ButtonBuilder and StringSelectMenuBuilder, placed in an ActionRowBuilder (the row), and sent in the components list. When pressed, the InteractionCreate event fires: i.isButton() or i.isStringSelectMenu() tell you what it is, i.customId which one, and i.values what was picked in the menu.

This bot publishes /rules, with the rules in pages and “Previous” and “Next” buttons, and /help, with a topic menu only the person who asked can see. The page number goes inside the customId (rules:2): that way nothing needs storing and the buttons keep working even if you restart the bot. i.update edits the same message instead of sending a new one. It needs no privileged intent.

index.js
require("dotenv").config(); // reads the token from the .env file
const { ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder, Events, GatewayIntentBits, MessageFlags, SlashCommandBuilder, StringSelectMenuBuilder } = require("discord.js");

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

// The pages of /rules: replace them with yours
const RULES = [
  "1. Be respectful to everyone.\n2. No spam or advertising.",
  "3. No cheats or hacks on the game server.\n4. Moderators have the final say.",
  "5. To report something, open a ticket.",
];

// The topics of /help: each option's value is its key here
const TOPICS = {
  ip: { label: "How to join the server", text: "The address is play.example.com" },
  events: { label: "Event schedule", text: "Events are on Saturdays at 18:00." },
  staff: { label: "Talk to the staff", text: "Open a ticket in #support." },
};

const commands = [
  new SlashCommandBuilder().setName("rules").setDescription("The rules, page by page"),
  new SlashCommandBuilder().setName("help").setDescription("Pick a help topic"),
];

function rulesPage(n) {
  const embed = new EmbedBuilder()
    .setTitle("Server rules")
    .setDescription(RULES[n])
    .setFooter({ text: `Page ${n + 1} of ${RULES.length}` });
  // the page each button leads to goes in its customId: nothing to store anywhere
  const row = new ActionRowBuilder().addComponents(
    new ButtonBuilder().setCustomId(`rules:${n - 1}`).setLabel("Previous").setStyle(ButtonStyle.Secondary).setDisabled(n === 0),
    new ButtonBuilder().setCustomId(`rules:${n + 1}`).setLabel("Next").setStyle(ButtonStyle.Primary).setDisabled(n === RULES.length - 1),
  );
  return { embeds: [embed], components: [row] };
}

const helpMenu = new ActionRowBuilder().addComponents(
  new StringSelectMenuBuilder()
    .setCustomId("help")
    .setPlaceholder("What do you need help with?")
    .addOptions(Object.entries(TOPICS).map(([value, t]) => ({ label: t.label, value }))),
);

client.on(Events.InteractionCreate, async (i) => {
  if (i.isChatInputCommand() && i.commandName === "rules") {
    await i.reply(rulesPage(0));
  } else if (i.isChatInputCommand() && i.commandName === "help") {
    await i.reply({ content: "Pick a topic:", components: [helpMenu], flags: MessageFlags.Ephemeral });
  } else if (i.isButton() && i.customId.startsWith("rules:")) {
    const n = Math.min(Math.max(Number(i.customId.split(":")[1]) || 0, 0), RULES.length - 1);
    await i.update(rulesPage(n)); // edits the same message instead of sending a new one
  } else if (i.isStringSelectMenu() && i.customId === "help") {
    const topic = TOPICS[i.values[0]]; // the choice: the values of the selected options
    await i.update({ content: `**${topic.label}**\n${topic.text}`, components: [helpMenu] });
  }
});

client.once(Events.ClientReady, async (c) => {
  await c.application.commands.set(commands.map((x) => x.toJSON())); // publishes /rules and /help
  console.log(`Logged in as ${c.user.tag}`);
});

client.login(process.env.DISCORD_TOKEN);

In discord.py

With a discord.ui.View: each button is a method with @discord.ui.button and each menu, with @discord.ui.select; the method receives the interaction and the button or menu itself (select.values is the choice). The view is sent with view= in send_message, and interaction.response.edit_message edits the same message.

Here the page is stored in the view itself (self.n), so the view expires: after 5 minutes untouched it stops listening (180 seconds by default) and on_timeout disables the buttons so nobody presses one that no longer answers. For buttons that never expire, see the section on restarting the bot. The same bot with /rules and /help:

app.py
import os

import discord
from discord import app_commands
from dotenv import load_dotenv

load_dotenv()  # reads the token from the .env file

# The pages of /rules: replace them with yours
RULES = [
    "1. Be respectful to everyone.\n2. No spam or advertising.",
    "3. No cheats or hacks on the game server.\n4. Moderators have the final say.",
    "5. To report something, open a ticket.",
]

# The topics of /help: each option's value is its key here
TOPICS = {
    "ip": ("How to join the server", "The address is play.example.com"),
    "events": ("Event schedule", "Events are on Saturdays at 18:00."),
    "staff": ("Talk to the staff", "Open a ticket in #support."),
}

client = discord.Client(intents=discord.Intents.default())
tree = app_commands.CommandTree(client)


def rules_page(n: int) -> discord.Embed:
    embed = discord.Embed(title="Server rules", description=RULES[n])
    embed.set_footer(text=f"Page {n + 1} of {len(RULES)}")
    return embed


class Pages(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=300)  # after 5 minutes untouched, it stops listening
        self.n = 0
        self.message = None
        self.update_buttons()

    def update_buttons(self):
        self.previous.disabled = self.n == 0
        self.next.disabled = self.n == len(RULES) - 1

    @discord.ui.button(label="Previous", style=discord.ButtonStyle.secondary)
    async def previous(self, interaction: discord.Interaction, button: discord.ui.Button):
        self.n -= 1
        self.update_buttons()
        # edits the same message instead of sending a new one
        await interaction.response.edit_message(embed=rules_page(self.n), view=self)

    @discord.ui.button(label="Next", style=discord.ButtonStyle.primary)
    async def next(self, interaction: discord.Interaction, button: discord.ui.Button):
        self.n += 1
        self.update_buttons()
        await interaction.response.edit_message(embed=rules_page(self.n), view=self)

    async def on_timeout(self):
        # disables the buttons so nobody presses one that no longer answers
        for item in self.children:
            item.disabled = True
        if self.message:
            await self.message.edit(view=self)


class Help(discord.ui.View):
    @discord.ui.select(
        placeholder="What do you need help with?",
        options=[discord.SelectOption(label=title, value=key) for key, (title, _) in TOPICS.items()],
    )
    async def topic(self, interaction: discord.Interaction, select: discord.ui.Select):
        title, text = TOPICS[select.values[0]]  # the choice: the values of the selected options
        await interaction.response.edit_message(content=f"**{title}**\n{text}", view=self)


@tree.command(name="rules", description="The rules, page by page")
async def rules(interaction: discord.Interaction):
    view_ = Pages()
    await interaction.response.send_message(embed=rules_page(0), view=view_)
    view_.message = await interaction.original_response()  # to disable it when it expires


@tree.command(name="help", description="Pick a help topic")
async def help_(interaction: discord.Interaction):
    await interaction.response.send_message("Pick a topic:", view=Help(), ephemeral=True)


@client.event
async def on_ready():
    await tree.sync()  # publishes /rules and /help
    print(f"Logged in as {client.user}")


client.run(os.environ["DISCORD_TOKEN"])

Replying in time: “This interaction failed”

Discord gives 3 seconds to answer each press. If the bot doesn't answer in that time, the person who pressed sees “This interaction failed” and the bot later gets error 10062 (“Unknown interaction”) if it tries to answer late. It also happens if the bot is off, if no code handles that custom_id or if the discord.py view has already expired.

There are three ways to answer: with a new message (i.reply in discord.js, interaction.response.send_message in discord.py; with ephemeral only the person who pressed sees it), by editing the button's message (i.update, interaction.response.edit_message) or by saying “on it” and answering later (i.deferUpdate(), interaction.response.defer()). After acknowledging, you have 15 minutes to edit the message or send more.

You only answer once: a second response gives error 40060 (“Interaction has already been acknowledged”). To send something else, use i.followUp in discord.js or interaction.followup.send in discord.py.

discord.js
// something slow (a database, a website): first tell Discord…
await i.deferUpdate();
const data = await somethingSlow();
// …then edit the button's message (you have 15 minutes)
await i.editReply({ content: data, components: [] });
discord.py
# something slow (a database, a website): first tell Discord…
await interaction.response.defer()
data = await something_slow()
# …then edit the button's message (you have 15 minutes)
await interaction.edit_original_response(content=data, view=None)

Keeping buttons working after a bot restart

A message with buttons stays in the channel forever, but the bot only knows what to do with them while its code handles them. In discord.js, if you handle them in InteractionCreate by their customId (as in the example), they also work on messages from before a restart. Those handled with a createMessageComponentCollector or an awaitMessageComponent don't: the collector lives in the bot's memory and is lost on restart.

In discord.py, to stop a view from expiring: timeout=None, a fixed custom_id on every button and menu, and register it at startup with add_view. That way the bot listens again to the buttons of messages already posted (a rules button, an open-ticket button). If the custom_id has to carry changing data (a number, an ID), discord.py has discord.ui.DynamicItem, which reads it with a regular expression.

app.py
class Rules(discord.ui.View):
    def __init__(self):
        super().__init__(timeout=None)  # never expires

    # with a fixed custom_id, the bot recognises it even if the message is from before a restart
    @discord.ui.button(label="I accept the rules", style=discord.ButtonStyle.success, custom_id="rules:accept")
    async def accept(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.send_message("Thanks!", ephemeral=True)


class Bot(discord.Client):
    async def setup_hook(self):
        self.add_view(Rules())  # listens again to the buttons of messages already posted

User, role and channel menus

Besides the text menu there are menus Discord fills in by itself with the server's things: users, roles, channels and “mentionables” (users and roles together). They have no options: they have a search box, and the bot gets what was picked.

In discord.js they are UserSelectMenuBuilder, RoleSelectMenuBuilder, ChannelSelectMenuBuilder and MentionableSelectMenuBuilder; i.values brings the IDs. In discord.py, @discord.ui.select(cls=discord.ui.UserSelect) (or RoleSelect, ChannelSelect, MentionableSelect), and select.values already brings the members, roles or channels. For people to add and remove roles with buttons, there's a whole bot in the role bot.

Limits and the usual errors

According to Discord's documentation: up to 5 buttons per row, or one menu alone in its row; up to 5 rows in a normal message; the custom_id, 1 to 100 characters; a button's text, up to 80; a menu, up to 25 options, with text, value and description of up to 100 each; the menu's hint text (placeholder), up to 150. Going over, or repeating the same custom_id on two buttons of the same message, gives error 50035 (“Invalid Form Body”) and nothing is sent. So does a link button with a custom_id, or a normal one without it.

Error 50013 (“Missing Permissions”) when pressing a button isn't the button's fault: it's what the bot tries to do when answering (give a role, delete a message) without having the permission. To give roles, the bot needs “Manage Roles” and its role must be above the one it hands out. The permissions it needs, with its invite link, come out of the bot permissions calculator.

If the button works for one person and not for another, remember everyone presses the same message: on the /rules pages, whoever turns the page turns it for everyone. If you want each person to go at their own pace, send the message with ephemeral (only the person who asked sees it), like /help.

Keeping it online all the time

A button only answers if the bot is on: with the bot off, every press is a “This interaction failed”. If you run it on your computer, it goes off with you. Free and paid options, compared, in where to host your Discord bot.

NapNodes has a free 256 MB bot that doesn't turn off when idle; you renew it every 7 days with “Start” (its catches, in this guide). Create it, pick Node.js or Python in “Settings” → “Language” and upload your files in “Files” (or put your repository in “Settings” → “GitHub repository”); whatever the bot prints you see in “Console”.

Frequently asked questions

How do I add a button to a discord.js bot?

With new ButtonBuilder().setCustomId("something").setLabel("Text").setStyle(ButtonStyle.Primary), inside an ActionRowBuilder, sent in components. When pressed, InteractionCreate fires with i.isButton() and its i.customId.

How do I add a button in discord.py?

With a class that inherits from discord.ui.View and a method with @discord.ui.button(label=…, style=…), sent with send_message(…, view=MyView()).

Why does “This interaction failed” show up when pressing a button?

Because the bot didn't answer within 3 seconds: it's off, no code handles that button, the discord.py view has expired or it takes too long. If it's going to take a while, answer first with deferUpdate() or defer().

How many buttons fit in a Discord message?

5 per row and up to 5 rows in a normal message: 25. A select menu takes up a whole row.

Do buttons keep working if I restart the bot?

In discord.js, if you handle them by their customId in InteractionCreate, yes. In discord.py, only if the view has timeout=None, fixed custom_ids and you register it with add_view at startup.

Shall we set it up for you?

Discord bot hosting from €1.50 a month, never oversold: your RAM is yours alone. And if you'd rather start without paying, there's a free Discord bot, no card needed.

Free tool: Discord bot permissions calculator. Tick your bot's permissions and get the number and the invite link; or paste a number and see which permissions it grants.

Keep reading

All Discord bot guides, sorted →