A Discord reaction role system lets members give themselves a role by reacting to a message with an emoji or clicking a button. Instead of handing out "color roles", "notification roles" or "game interest" roles one by one, you let members choose for themselves; the server stays tidier and the moderation load drops. In this guide we'll build self-role logic from scratch: first with the classic emoji reaction, then with the modern button approach recommended today. The code examples target discord.js v14 and Node.js.
Which method: emoji reaction or button?
There are two core approaches, and it pays to know the difference:
- Reaction (emoji) method: The member clicks an emoji on the message, the bot listens to the
messageReactionAddevent and grants the role; removing the emoji removes the role. Classic and familiar, but the bot needs an extraintentto see reactions, and emoji management is fragile. - Button method: You attach buttons to the message, the member clicks one, and the bot toggles the role via
interactionCreate. Cleaner, more reliable on mobile, and the modern path Discord steers you toward.
For new projects I recommend the button method. Still, since the emoji approach is very common on older servers, I'll show that too.
Required permissions and intents
The bot needs the Manage Roles permission to manage roles. There's one more golden rule: the bot's own role must sit above the roles it hands out. Discord only lets a bot assign roles that are below its highest role. So in Server Settings > Roles, drag the bot's role above the target roles.
Pick the right intents when creating the client. For the button method Guilds is enough; the emoji method also needs GuildMessageReactions:
const { Client, GatewayIntentBits, Partials } = require('discord.js');
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessageReactions, // only for the emoji method
],
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
});
partials matter: they let the bot catch reactions on older messages added while it was offline, otherwise those reactions are ignored after a restart.
Setting up self-roles with buttons
First we send a message containing the role buttons. We embed the target role's ID into each button's customId so we know which role was requested on click:
const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
// Called from a command/handler
async function sendRolePanel(channel) {
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('role:123456789012345678') // target role ID
.setLabel('Event Pings')
.setStyle(ButtonStyle.Primary),
new ButtonBuilder()
.setCustomId('role:987654321098765432')
.setLabel('Announcements')
.setStyle(ButtonStyle.Secondary),
);
await channel.send({
content: 'Click a button to grab or drop the role:',
components: [row],
});
}
Now we listen for clicks and toggle the role. If the member already has it we remove it, otherwise we add it:
client.on('interactionCreate', async (interaction) => {
if (!interaction.isButton()) return;
if (!interaction.customId.startsWith('role:')) return;
const roleId = interaction.customId.split(':')[1];
const member = interaction.member;
try {
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 granted!', ephemeral: true });
}
} catch (err) {
console.error(err);
await interaction.reply({
content: 'I could not set the role. Check my permissions and the role order.',
ephemeral: true,
});
}
});
ephemeral: true shows the reply only to the member who clicked, so it doesn't spam the channel. The try/catch returns a clean error message if the role hierarchy is wrong, informing the user instead of failing silently.
Classic self-roles with emoji reactions
If you prefer the emoji method, you need to store the panel message's ID and the emoji-to-role mapping somewhere. A simple object is fine for small servers; on larger projects you'd move this into a database (e.g. SQLite):
const PANEL_MESSAGE_ID = '111111111111111111';
const map = {
'🔴': '123456789012345678', // red role
'🟢': '987654321098765432', // green role
};
client.on('messageReactionAdd', async (reaction, user) => {
if (user.bot) return;
if (reaction.message.id !== PANEL_MESSAGE_ID) return;
if (reaction.partial) await reaction.fetch();
const roleId = map[reaction.emoji.name];
if (!roleId) return;
const member = await reaction.message.guild.members.fetch(user.id);
await member.roles.add(roleId).catch(console.error);
});
client.on('messageReactionRemove', async (reaction, user) => {
if (user.bot) return;
if (reaction.message.id !== PANEL_MESSAGE_ID) return;
if (reaction.partial) await reaction.fetch();
const roleId = map[reaction.emoji.name];
if (!roleId) return;
const member = await reaction.message.guild.members.fetch(user.id);
await member.roles.remove(roleId).catch(console.error);
});
Note: for custom emojis, match on reaction.emoji.id instead of reaction.emoji.name, because names can change but the ID is stable.
Security and common mistakes
- Role order: This is the most common issue. If the bot's role sits below the target role, you'll get a "Missing Permissions" error.
- Managed roles: Bot/integration roles or the Nitro Booster role can't be assigned manually; don't put them on the panel.
- Privilege escalation: Never put roles with dangerous permissions like
Manage RolesorAdministratoron the panel, or anyone can make themselves staff. - Pin the message ID: Don't forget the check that only reacts to the panel message, otherwise the bot reacts to every message.
Frequently Asked Questions
Which intents does a reaction role bot need?
The button method only needs Guilds. The emoji method also requires the GuildMessageReactions intent, plus the partials setting to catch reactions added while the bot was offline.
Why must the bot's role be above the target role?
Due to a Discord security rule, a bot can only add or remove roles that sit below its own highest role. If you don't drag the bot's role above the roles it hands out in Server Settings > Roles, you'll hit a "Missing Permissions" error.
What happens if a member reacts while the bot is offline?
With the button method there's no problem because the interaction is processed when the bot comes back. With the emoji method you'll miss that reaction unless you've defined partials — which is exactly why the button method is more reliable.
Want a clean, secure self-role system for your server? I can build a button-based, database-backed Discord bot with solid error handling. Get in touch with me for your project.