Self-assign roles with buttons (discord.js)

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 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 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.
  • 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 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.
  • 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 InteractionCreate no matter how old the message is — even after your bot restarts. As long as the role: 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.Secondary for Primary (blurple), Success (green), or Danger (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 in components: [...].
  • Persist the config. Move ROLES into 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 checks startsWith('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 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.

Next steps

Was this guide helpful?