Only show guides for your app
Let members give themselves roles by clicking buttons — the modern replacement for emoji reaction roles — with a customId trick that survives restarts.
"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 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.
ROLES list 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.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 checks customId.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.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.)
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.
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.
ButtonStyle.Secondary for Primary (blurple), Success (green), or Danger (red).ActionRowBuilders in components: [...].ROLES into a JSON file or database so you can edit it without touching code — see Store data for your bot.isButton() branch is missing or your customId prefix doesn't match. The code checks startsWith('role:'); keep them in sync.ROLES is wrong, or you left the placeholder IDs in. Copy real IDs with Copy Role ID./rolemenu never appears — first-time global registration takes a few minutes; then re-invite with applications.commands if still missing.