"Reaction roles" is the classic feature where members pick their own roles — for pings, colours, or opting into channels. The old way used emoji reactions; the modern, tidier way uses buttons. This recipe posts a row of role buttons with a /rolemenu command, and toggles the role on and off when someone clicks.
| At a glance | |
|---|---|
| You need | a working bot from Host a discord.js bot, and the role IDs you want to offer |
| Plan | Free or premium |
| Time | About twenty minutes |
This assumes the discord.js bot guide. Buttons in general are covered in Slash commands in depth — here we put them to work.
The code
The complete index.js. Edit the ROLES list with your own role IDs (Developer Mode on → right-click a role in Server Settings → Copy Role ID):
require('dotenv').config();
const {
Client, Events, GatewayIntentBits,
SlashCommandBuilder, PermissionFlagsBits,
ActionRowBuilder, ButtonBuilder, ButtonStyle,
} = require('discord.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
// Edit this list: one entry per self-assignable role.
const ROLES = [
{ id: '111111111111111111', label: 'Announcements', emoji: '📣' },
{ id: '222222222222222222', label: 'Events', emoji: '🎉' },
{ id: '333333333333333333', label: 'Polls', emoji: '📊' },
];
const setup = new SlashCommandBuilder()
.setName('rolemenu')
.setDescription('Post the self-role buttons in this channel')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles);
client.once(Events.ClientReady, async (c) => {
await c.application.commands.set([setup]);
console.log(`Listening as ${c.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (interaction.isChatInputCommand() && interaction.commandName === 'rolemenu') {
const row = new ActionRowBuilder().addComponents(
...ROLES.map(r =>
new ButtonBuilder()
.setCustomId(`role:${r.id}`)
.setLabel(r.label)
.setEmoji(r.emoji)
.setStyle(ButtonStyle.Secondary)),
);
await interaction.reply({ content: 'Click a button to give yourself a role (click again to remove it):', components: [row] });
return;
}
if (interaction.isButton() && interaction.customId.startsWith('role:')) {
const roleId = interaction.customId.split(':')[1];
const member = interaction.member;
if (member.roles.cache.has(roleId)) {
await member.roles.remove(roleId);
await interaction.reply({ content: 'Role removed.', ephemeral: true });
} else {
await member.roles.add(roleId);
await interaction.reply({ content: 'Role added!', ephemeral: true });
}
}
});
client.login(process.env.DISCORD_TOKEN);
Restart, wait for /rolemenu to appear, then run it in the channel where you want the buttons.
The parts that matter
- The
ROLESlist is your whole configuration. Each entry is one button: a role ID, a label, and an emoji.ROLES.map(...)turns the list into buttons, so adding a role is one more line. - The customId is set to
role:plus the role's ID. When someone clicks, Discord hands that same string back —customId.split(':')[1]pulls the role ID out. Packing data into the customId this way is the standard pattern for buttons that carry information. interaction.isButton()routes clicks. Because the handler checkscustomId.startsWith('role:'), one handler serves every role button, and it won't collide with buttons from other features.member.roles.cache.has(roleId)is what makes each button a toggle: if the member already has the role, remove it; otherwise add it. One button both grants and takes away.- The ephemeral replies (
ephemeral: true) confirm the change only to the clicker, so the channel doesn't fill up with "Role added!" every time.
🎯 Good to know: In discord.js, button clicks arrive through
InteractionCreateno matter how old the message is — even after your bot restarts. As long as therole:handler exists in your code, buttons you posted days ago keep working with no extra setup. (discord.py handles this differently; see the Python version.)
More than five roles? Use a select menu
An action row holds at most five buttons. For a longer list, swap the buttons for a select menu (dropdown) — see Select menus for choices, where each option's value is a role ID and you read the picks with interaction.values.
Permissions and intents
No privileged intents — GatewayIntentBits.Guilds is enough.
| Requirement | Detail |
|---|---|
| Manage Roles permission | the bot's role needs it to add or remove roles |
| Role position | the bot's highest role must sit above every role in your ROLES list |
⚠️ Heads up: A bot can only grant roles below its own highest role — no permission overrides Discord's role hierarchy. If a click errors with "Missing Permissions", drag the bot's role above the self-assignable ones in Server Settings → Roles.
setDefaultMemberPermissions(ManageRoles)restricts who can run/rolemenu, but the button clicks are open to everyone by design.
Making it yours
- Colour the buttons. Swap
ButtonStyle.SecondaryforPrimary(blurple),Success(green), orDanger(red). - One exclusive choice. For "pick one colour, not several", remove the member's other colour roles before adding the new one.
- Split into groups. Send several rows (up to five rows per message) — one row for pings, one for colours — by passing multiple
ActionRowBuilders incomponents: [...]. - Persist the config. Move
ROLESinto a JSON file or database so you can edit it without touching code — see Store data for your bot.
Troubleshooting
- Clicking a button does nothing — the
isButton()branch is missing or your customId prefix doesn't match. The code checksstartsWith('role:'); keep them in sync. - "Missing Permissions" on click — the bot lacks Manage Roles, or its role is below the role it's trying to hand out. Fix both in Server Settings → Roles.
- "Invalid Form Body" when posting the menu — a role ID in
ROLESis wrong, or you left the placeholder IDs in. Copy real IDs with Copy Role ID. /rolemenunever appears — first-time global registration takes a few minutes; then re-invite withapplications.commandsif still missing.